From 3096430419482fae52edf552b19f98e79e0890d3 Mon Sep 17 00:00:00 2001 From: Marcus Pasell <3690498+rickyrombo@users.noreply.github.com> Date: Tue, 27 Oct 2026 14:28:12 -0700 Subject: [PATCH] fix(api): mark unclaimed_id and block_confirmation responses no-store An edge cache in front of /v1/*/unclaimed_id returned the same id to concurrent callers. Clients that fetch an id per track in parallel (bulk album uploads) then sent two creates with one id, and every create after the first was rejected as a duplicate. The SDK's cache-buster query param has only 1000 values, so it does not prevent this. /block_confirmation is polled until a write's block is indexed; a cached "not yet" delays confirmation past the client's timeout. Both now send Cache-Control: no-store, which edge caches honor when they respect origin headers. Co-Authored-By: Claude Opus 5.5 --- api/block_confirmation.go | 3 +++ api/v1_unclaimed_id.go | 4 ++++ api/v1_unclaimed_id_test.go | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 api/v1_unclaimed_id_test.go diff --git a/api/block_confirmation.go b/api/block_confirmation.go index f96b523e..fc0ecdef 100644 --- a/api/block_confirmation.go +++ b/api/block_confirmation.go @@ -55,6 +55,9 @@ func (app *ApiServer) BlockConfirmation(c *fiber.Ctx) error { return err } + // Clients poll this until their block is indexed; a cached "not yet" + // keeps them waiting after it has been. + c.Set(fiber.HeaderCacheControl, "no-store") return c.JSON(fiber.Map{ "data": res, }) diff --git a/api/v1_unclaimed_id.go b/api/v1_unclaimed_id.go index fe77126b..8ccb6408 100644 --- a/api/v1_unclaimed_id.go +++ b/api/v1_unclaimed_id.go @@ -34,6 +34,10 @@ func (app *ApiServer) v1UnclaimedId(c *fiber.Ctx, tableName, idField string, low } id, _ := trashid.EncodeHashId(freeId) + // Each response must be a fresh id: a cached one hands the same id to + // concurrent callers, and every write after the first that uses it is + // rejected as a duplicate. + c.Set(fiber.HeaderCacheControl, "no-store") return c.JSON(fiber.Map{ "data": id, }) diff --git a/api/v1_unclaimed_id_test.go b/api/v1_unclaimed_id_test.go new file mode 100644 index 00000000..7e4d93df --- /dev/null +++ b/api/v1_unclaimed_id_test.go @@ -0,0 +1,34 @@ +package api + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" +) + +// An edge cache in front of these routes hands one response to every caller, +// so each must tell caches not to store it. +func TestUnclaimedIdAndBlockConfirmationAreNotCacheable(t *testing.T) { + app := emptyTestApp(t) + + for _, path := range []string{ + "/v1/users/unclaimed_id", + "/v1/tracks/unclaimed_id", + "/v1/playlists/unclaimed_id", + "/v1/playlists/unclaimed-id", + "/v1/comments/unclaimed_id", + "/v1/comments/unclaimed-id", + "/v1/events/unclaimed_id", + "/v1/events/unclaimed-id", + "/block_confirmation?blocknumber=1&blockhash=0xabc", + "/block-confirmation?blocknumber=1&blockhash=0xabc", + } { + t.Run(path, func(t *testing.T) { + res, err := app.Test(httptest.NewRequest("GET", path, nil), -1) + assert.NoError(t, err) + assert.Equal(t, 200, res.StatusCode) + assert.Equal(t, "no-store", res.Header.Get("Cache-Control")) + }) + } +}