Skip to content

Commit d708072

Browse files
committed
fix(middleware): skip idempotency cache for mutable error codes (BUG-API-238)
BUG-API-238 — the free-tier recycle gate emits 402 `free_tier_recycle_requires_claim` with a claim_url the user can act on in 30 seconds. Pre-fix the 402 was written to the explicit-key 24h cache, so this sequence stranded the agent on a stale failure: 1. Agent POST /db/new with Idempotency-Key K1 → 402 (cached against K1). 2. User follows claim_url, claims with email (clears the gate state). 3. Agent retries POST /db/new with K1 → replays the cached 402. The 24h cache TTL is the Stripe contract — same key replays the same response — but it presumes a STABLE outcome. A 402 that flips when the user takes a 30-second action is not stable. Fix: - mutableErrorCodes registry (currently { free_tier_recycle_requires_claim }) listing error codes whose 4xx resolution can flip inside the cache TTL. - shouldCacheResponse(status, body, ct) helper that defers to caching for success + non-JSON + stable 4xx, but skips for body.error ∈ mutable map. - Wired into BOTH explicit-key (24h TTL) and fingerprint-fallback (120s) cache-write paths so the bypass is symmetric. What did NOT change: - Stripe-shape contract for stable outcomes (success + quota_exceeded + upgrade_required + provision_limit_reached etc.) — those still cache. - 409 idempotency_key_conflict envelope (BUG-013/406) unchanged. - No new endpoints, no new fields, no auth changes. Surface checklist (rule 22): - api/internal/middleware/idempotency.go helper + 2 wires - api/internal/middleware/idempotency_mutable_cache_test.go new regression - dashboard / marketing / OpenAPI no surface change (Stripe contract preserved; response shape unchanged) Coverage block: Symptom: Idempotent retry replays stale 402 after user clears recycle gate Enumeration: rg -F 'rdb.Set(context.Background(), cacheKey' internal/middleware/idempotency.go Sites found: 2 cache-write paths (explicit + fingerprint) Sites touched: 2 / 2 Coverage test: TestShouldCacheResponse_MutableErrorsSkipCache iterates the live mutableErrorCodes map (rule 18 — registry-iterating, not hand-typed); TestIdempotency_RecycleGate402_SourceAssertion static-grep that BOTH branches invoke shouldCacheResponse (rule 16). Live verified: pre-merge curl evidence pending — see PR body Local gate: - go build ./... PASS - go vet ./... PASS - go test ./internal/middleware/ PASS (full suite) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 61faeb1 commit d708072

2 files changed

Lines changed: 208 additions & 0 deletions

File tree

internal/middleware/idempotency.go

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,85 @@ type idemEntry struct {
159159
BodyHash string `json:"h"` // sha256 hex of the original request body
160160
}
161161

162+
// mutableErrorCodes lists machine-readable `error` strings whose 4xx
163+
// resolution can flip BEFORE the explicit-key 24h cache TTL elapses,
164+
// so caching them silently strands the agent on the stale failure.
165+
//
166+
// BUG-API-238 (QA 2026-05-29): the canonical case is
167+
// `free_tier_recycle_requires_claim`. Sequence:
168+
//
169+
// 1. Agent calls POST /db/new with Idempotency-Key K1. The recycle gate
170+
// fires → 402 free_tier_recycle_requires_claim cached against K1.
171+
// 2. User follows the claim_url and successfully claims (POST /claim).
172+
// The recycle gate state is now CLEARED — a fresh /db/new without
173+
// a key would succeed.
174+
// 3. Agent retries the original /db/new with Idempotency-Key K1. The
175+
// pre-fix path replays the cached 402 verbatim, even though the
176+
// gate would now wave the caller through. Agent thinks the claim
177+
// failed; user thinks the platform is broken.
178+
//
179+
// Excluding these codes from caching restores the Stripe-shape contract
180+
// ("same key replays the same response") in the spirit it was written:
181+
// a STABLE outcome is replayed. A 402 that disappears the moment the
182+
// user takes 30 seconds to claim is not a stable outcome.
183+
//
184+
// We do NOT exclude generic `quota_exceeded` / `upgrade_required` /
185+
// `provision_limit_reached` — those resolve on a calendar boundary
186+
// (next UTC day) or a payment event, both of which are outside the
187+
// agent's reach inside the 24h key TTL. The recycle gate is the
188+
// distinguishing case: a user-initiated action a few seconds later
189+
// clears it.
190+
//
191+
// The list is small on purpose. Adding a code here means "this gate
192+
// can clear inside 24h without server-side state change" — which is
193+
// the actual cache-coherence boundary. Any future addition needs a
194+
// regression test that exercises the same-key-retry-after-resolution
195+
// path.
196+
var mutableErrorCodes = map[string]struct{}{
197+
"free_tier_recycle_requires_claim": {},
198+
}
199+
200+
// shouldCacheResponse decides whether a non-5xx response should be
201+
// written to the idempotency cache. Success (<400) always caches —
202+
// that's the Stripe contract. 4xx responses cache UNLESS the body's
203+
// `error` field is in mutableErrorCodes (BUG-API-238).
204+
//
205+
// JSON peek is non-strict: any parse error / non-JSON body / missing
206+
// `error` field falls back to caching (the pre-fix behaviour). Only a
207+
// well-formed envelope whose `error` is listed gets skipped, so the
208+
// helper degrades safely on unexpected bodies.
209+
func shouldCacheResponse(status int, body []byte, contentType string) bool {
210+
// Success responses always cache — the Stripe-shape contract guarantees
211+
// the agent can replay them. Mutability only matters for failures the
212+
// caller might re-resolve.
213+
if status < 400 {
214+
return true
215+
}
216+
// Quick rejects: non-JSON bodies (e.g. text/html 4xx) can't carry the
217+
// `error` field shape we filter on, so default-cache them.
218+
if !strings.Contains(contentType, "json") {
219+
return true
220+
}
221+
if len(body) == 0 {
222+
return true
223+
}
224+
// Peek the `error` field. Use a minimal struct to avoid pulling in the
225+
// handlers package's ErrorResponse (which would create a circular
226+
// import — handlers depends on middleware).
227+
var peek struct {
228+
Error string `json:"error"`
229+
}
230+
if err := json.Unmarshal(body, &peek); err != nil {
231+
// Malformed JSON body — defer to default (cache). The handler
232+
// emitted bytes the test suite is responsible for catching.
233+
return true
234+
}
235+
if _, mutable := mutableErrorCodes[peek.Error]; mutable {
236+
return false
237+
}
238+
return true
239+
}
240+
162241
// Idempotency returns a Fiber handler that dedups duplicate POSTs via two
163242
// layered mechanisms:
164243
//
@@ -341,6 +420,20 @@ func idempotencyExplicit(c *fiber.Ctx, rdb *redis.Client, endpoint, scope, rawKe
341420
body := append([]byte(nil), c.Response().Body()...)
342421
ct := string(c.Response().Header.ContentType())
343422

423+
// BUG-API-238: bypass the cache for mutable error codes (currently
424+
// just free_tier_recycle_requires_claim). A 402 the user resolves
425+
// in 30s by clicking claim_url must not get strand-cached against
426+
// the agent's 24h Idempotency-Key. Success + stable failures still
427+
// cache as before.
428+
if !shouldCacheResponse(status, body, ct) {
429+
slog.Info("idempotency.skip_cache_mutable_error",
430+
"endpoint", endpoint,
431+
"status", status,
432+
"request_id", GetRequestID(c),
433+
)
434+
return nextErr
435+
}
436+
344437
entry := idemEntry{
345438
StatusCode: status,
346439
Body: body,
@@ -454,6 +547,20 @@ func idempotencyFingerprint(c *fiber.Ctx, rdb *redis.Client, endpoint, scope str
454547
body := append([]byte(nil), c.Response().Body()...)
455548
ct := string(c.Response().Header.ContentType())
456549

550+
// BUG-API-238: same mutable-error bypass as the explicit-key path.
551+
// The fingerprint TTL is only 120s but the recycle gate still flips
552+
// inside that window when a user claims fast — and the fingerprint
553+
// path is the no-header default, so the silent strand is even more
554+
// likely here.
555+
if !shouldCacheResponse(status, body, ct) {
556+
slog.Info("idempotency.fingerprint_skip_cache_mutable_error",
557+
"endpoint", endpoint,
558+
"status", status,
559+
"request_id", GetRequestID(c),
560+
)
561+
return nextErr
562+
}
563+
457564
entry := idemEntry{
458565
StatusCode: status,
459566
Body: body,
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
package middleware
2+
3+
// idempotency_mutable_cache_test.go — BUG-API-238 regression.
4+
//
5+
// The shouldCacheResponse helper governs whether a non-5xx response is
6+
// written to the explicit-key (24h) or fingerprint (120s) idempotency
7+
// cache. Most 4xx responses cache; a small allowlist of "mutable" error
8+
// codes (currently free_tier_recycle_requires_claim) MUST skip the cache
9+
// so a user-side action (e.g. claiming with email) that clears the gate
10+
// is honoured on the agent's next retry of the same Idempotency-Key.
11+
//
12+
// Whitebox test (same package) so we can exercise shouldCacheResponse
13+
// directly without spinning a Redis fake.
14+
15+
import (
16+
"os"
17+
"strings"
18+
"testing"
19+
20+
"github.com/stretchr/testify/assert"
21+
"github.com/stretchr/testify/require"
22+
)
23+
24+
// TestShouldCacheResponse_DefaultBehaviour covers the pre-fix contract:
25+
// success caches, stable 4xx caches, non-JSON caches.
26+
func TestShouldCacheResponse_DefaultBehaviour(t *testing.T) {
27+
tests := []struct {
28+
name string
29+
status int
30+
body []byte
31+
ct string
32+
wantCache bool
33+
}{
34+
{"200 OK json", 200, []byte(`{"ok":true}`), "application/json", true},
35+
{"201 Created json", 201, []byte(`{"ok":true}`), "application/json", true},
36+
{"400 with quota_exceeded error caches (stable)", 402, []byte(`{"error":"quota_exceeded"}`), "application/json", true},
37+
{"400 with idempotency_key_conflict caches (stable)", 409, []byte(`{"error":"idempotency_key_conflict"}`), "application/json", true},
38+
{"4xx with provision_limit_reached caches (calendar boundary)", 429, []byte(`{"error":"provision_limit_reached"}`), "application/json", true},
39+
{"non-JSON 4xx caches (no error field to inspect)", 400, []byte(`<html>oops</html>`), "text/html", true},
40+
{"empty body caches (no error field to inspect)", 400, []byte(``), "application/json", true},
41+
{"malformed JSON caches (defer to default)", 400, []byte(`{not-json}`), "application/json", true},
42+
}
43+
for _, tc := range tests {
44+
t.Run(tc.name, func(t *testing.T) {
45+
got := shouldCacheResponse(tc.status, tc.body, tc.ct)
46+
assert.Equal(t, tc.wantCache, got,
47+
"shouldCacheResponse(status=%d, ct=%q) = %v; want %v", tc.status, tc.ct, got, tc.wantCache)
48+
})
49+
}
50+
}
51+
52+
// TestShouldCacheResponse_MutableErrorsSkipCache is the BUG-API-238
53+
// regression: every entry in the mutableErrorCodes map must return
54+
// false from shouldCacheResponse so the agent gets fresh handler output
55+
// on the next retry.
56+
func TestShouldCacheResponse_MutableErrorsSkipCache(t *testing.T) {
57+
require.NotEmpty(t, mutableErrorCodes,
58+
"BUG-API-238: mutableErrorCodes must list at least free_tier_recycle_requires_claim")
59+
60+
// Sanity: the canonical case is registered.
61+
_, ok := mutableErrorCodes["free_tier_recycle_requires_claim"]
62+
require.True(t, ok,
63+
"BUG-API-238: free_tier_recycle_requires_claim must be in mutableErrorCodes")
64+
65+
// Iterate the live registry (rule 18: registry-iterating regression
66+
// test, not a hand-typed list) so any future addition is automatically
67+
// covered.
68+
for code := range mutableErrorCodes {
69+
t.Run(code, func(t *testing.T) {
70+
// Recycle gate returns 402 with claim_url etc. — exercise the
71+
// representative case.
72+
body := []byte(`{"ok":false,"error":"` + code + `","claim_url":"https://instanode.dev/claim"}`)
73+
got := shouldCacheResponse(402, body, "application/json")
74+
assert.False(t, got,
75+
"BUG-API-238: 402 with error=%q must skip the cache; got cache=true", code)
76+
})
77+
}
78+
}
79+
80+
// TestIdempotency_RecycleGate402_SourceAssertion is a static-source
81+
// belt-and-suspenders: the call sites in both idempotency branches
82+
// (explicit + fingerprint) must invoke shouldCacheResponse before
83+
// writing. Without both wires the fix is half-applied (only one of
84+
// the two cache paths bypasses) — exactly the rule-16 modal failure
85+
// mode the agent-reliability rules call out.
86+
func TestIdempotency_RecycleGate402_SourceAssertion(t *testing.T) {
87+
src, err := os.ReadFile("idempotency.go")
88+
require.NoError(t, err)
89+
body := string(src)
90+
91+
// Both branches must call shouldCacheResponse. Count >= 2 so we
92+
// catch the case where someone deletes one of the two call sites.
93+
assert.GreaterOrEqual(t, strings.Count(body, "shouldCacheResponse("), 2,
94+
"BUG-API-238: shouldCacheResponse must be invoked on BOTH explicit and fingerprint cache-write paths (rule 16 — two emitters of one bug)")
95+
96+
// The mutable list must reference the canonical error code by string
97+
// so a future refactor that renames the constant (and forgets the
98+
// map key) is caught by grep.
99+
assert.Contains(t, body, `"free_tier_recycle_requires_claim"`,
100+
"BUG-API-238: mutableErrorCodes must reference free_tier_recycle_requires_claim by string")
101+
}

0 commit comments

Comments
 (0)