What
Fix claimKey() in idempotencyService.ts so it actually distinguishes "I am the original claimant of this key" from "I am a concurrent duplicate that just raced onto an in-progress row," per its own documented contract.
Why
The docstring for claimKey() (L19-21) states it "Throws if the key is in_progress (concurrent duplicate)". The actual implementation (L69-77) does a single INSERT ... ON CONFLICT DO UPDATE that resets the row's status to 'in_progress' regardless of which request "wins" the race, then unconditionally returns null ("let the caller proceed") whenever status is in_progress. This means when two requests race on the same idempotency key, both proceed to execute the underlying action the key was meant to deduplicate — the promised throw never fires. Given this key is used to guard payment-related actions, this defeats the entire purpose of idempotency protection under concurrent retries (e.g. a client double-submitting a payment request during a slow response).
Scope
In scope:
- Rework
claimKey()'s ON CONFLICT logic so exactly one concurrent request is recognized as the original claimant and any other racing request on the same key is correctly identified and rejected/throws
- Add a concurrency test that fires two simultaneous claims on the same key and asserts only one succeeds
- Audit call sites relying on
claimKey() to confirm they handle the corrected throw behavior correctly
Out of scope:
- Changes to how idempotency keys are generated by callers
- The HTTP-level
idempotencyMiddleware wiring itself (only the underlying service logic)
Acceptance Criteria
Technical Context
backend/src/services/idempotencyService.ts — docstring L19-21, claimKey() implementation L69-77 (INSERT ... ON CONFLICT DO UPDATE)
What
Fix
claimKey()inidempotencyService.tsso it actually distinguishes "I am the original claimant of this key" from "I am a concurrent duplicate that just raced onto an in-progress row," per its own documented contract.Why
The docstring for
claimKey()(L19-21) states it "Throws if the key is in_progress (concurrent duplicate)". The actual implementation (L69-77) does a singleINSERT ... ON CONFLICT DO UPDATEthat resets the row's status to'in_progress'regardless of which request "wins" the race, then unconditionally returnsnull("let the caller proceed") whenever status isin_progress. This means when two requests race on the same idempotency key, both proceed to execute the underlying action the key was meant to deduplicate — the promised throw never fires. Given this key is used to guard payment-related actions, this defeats the entire purpose of idempotency protection under concurrent retries (e.g. a client double-submitting a payment request during a slow response).Scope
In scope:
claimKey()'sON CONFLICTlogic so exactly one concurrent request is recognized as the original claimant and any other racing request on the same key is correctly identified and rejected/throwsclaimKey()to confirm they handle the corrected throw behavior correctlyOut of scope:
idempotencyMiddlewarewiring itself (only the underlying service logic)Acceptance Criteria
claimKey()calls on the same key: exactly one succeeds, the other throws/is rejected as a duplicatePromise.allof two claims) and fails against the old code, passes against the fixTechnical Context
backend/src/services/idempotencyService.ts— docstring L19-21,claimKey()implementation L69-77 (INSERT ... ON CONFLICT DO UPDATE)