Skip to content

fix: bind idempotency key identity to the request, not just its route scope - #77

Merged
chonilius merged 7 commits into
MergeFi:mainfrom
presidojay1:fix/idempotency-request-fingerprint-54
Aug 17, 2026
Merged

fix: bind idempotency key identity to the request, not just its route scope#77
chonilius merged 7 commits into
MergeFi:mainfrom
presidojay1:fix/idempotency-request-fingerprint-54

Conversation

@presidojay1

Copy link
Copy Markdown
Contributor

Summary

IdempotencyInterceptor determined whether a request was a replay purely from (Idempotency-Key, route scope, callerId)scope is a static string per route (e.g. 'escrow.release'), the same for every request to that route regardless of path params or body. Reusing an Idempotency-Key across two different resources on the same route (e.g. POST /escrow/A/release then POST /escrow/B/release with the same key) silently replayed A's cached response for B: the client saw an apparently-successful release, but B's real handler was never invoked and its funds never moved.

  • IdempotencyKey gains a nullable requestFingerprint column (migration AddIdempotencyKeyRequestFingerprint1784500000000) — a SHA-256 hash of the request's path params + body (deterministic key-sorted JSON, so key-order differences between logically-identical bodies don't false-positive).
  • resolveExisting compares the incoming request's fingerprint against the one stored on the existing row and rejects with 422 Unprocessable Entity on a mismatch — checked before the COMPLETED-replay or PROCESSING/staleness logic, since a mismatch means this was never "the same operation" regardless of the true owner's row state.
  • A null stored fingerprint (a row that predates this column) is exempt from the check, so the migration deploy transition doesn't 422 an in-flight legitimate retry.
  • Path params are folded into the body-hash comparison rather than into scope itself, so scope stays a purely route-level concept — see the class doc comment for the reasoning.

Closes #54

Test plan

  • npm run lint — clean (0 errors)
  • npx tsc --noEmit — clean
  • npm run build — succeeds
  • npm run test — 125/125 pass, including new coverage in idempotency.interceptor.spec.ts: reusing a key across two different resources (and separately, two different bodies) is rejected with 422 without the second handler ever running; a genuinely identical retry (same params + body) still replays correctly; a pre-migration row with no stored fingerprint is exempt from the check
  • npm run test:e2e (test/escrow-idempotency.e2e-spec.ts, new) — hits EscrowController's real POST /escrow/:id/release route end-to-end (mocked EscrowService, real IdempotencyInterceptor) and directly reproduces the issue's exact scenario: reusing escrow A's key against escrow B returns 422 and EscrowService.release is never called for B
  • Verified the migration's up()/down() SQL directly against a real local Postgres (adds/drops requestFingerprint cleanly)

Nullable hash column that will bind an idempotency key's identity to
the actual request (path params + body), not just its route scope
(MergeFi#54). Nullable so existing pre-migration rows are treated as
predating the check rather than breaking during the deploy
transition — see the follow-up commit wiring this into
IdempotencyInterceptor.
Maps the column added in the previous migration. Immutable once set —
a row represents one specific operation for its whole lifetime.
(key, scope, callerId) alone can't distinguish "retry of the same
operation" from "different operation that happens to share a key" —
scope is a static string per route, the same for every request
regardless of path params or body (MergeFi#54). resolveExisting now compares
a SHA-256 fingerprint of path params + body against the fingerprint
stored on the existing row and rejects with 422 on a mismatch, before
ever reaching the COMPLETED-replay or PROCESSING/staleness logic — a
mismatch means this was never "the same operation" regardless of what
state the true owner's row is in.

A null stored fingerprint (a row that predates this column) is exempt
from the check rather than treated as a guaranteed mismatch, so the
migration deploy transition doesn't 422 an in-flight legitimate retry.

Path params are folded into the body-hash comparison rather than into
`scope` itself, so scope stays a purely route-level concept and one
mechanism covers both params and body — see the class doc comment.
…i#54)

FakeIdempotencyRepo now stores requestFingerprint (defaulting to null
like an unspecified nullable Postgres column would, not undefined —
needed so rows seeded directly in tests correctly model a pre-MergeFi#54
row). createContext accepts params/body so tests can construct
requests that differ by resource or payload.
Covers the exact scenario the issue describes: the same Idempotency-
Key sent to two requests with different path params (and separately,
different bodies) on the same scope is rejected with 422 rather than
replaying the first request's cached response — the second
resource's handler is never invoked, matching the "escrow B was
never actually released despite an apparently-successful response"
failure mode from the issue. Also covers the null-fingerprint escape
hatch for pre-MergeFi#54 rows and confirms a genuinely identical retry
(same params + body) still replays correctly.
Hits EscrowController's real POST /escrow/:id/release route (mocked
EscrowService, in-memory idempotency repo, real IdempotencyInterceptor)
to prove the fix end-to-end rather than only at the interceptor-unit
level: reusing an Idempotency-Key across escrow A and escrow B
returns 422 for B and EscrowService.release is never called for B,
while retrying the same key against the same escrow/body still
replays correctly. Matches the issue's own reproduction plan.

(Includes a prettier line-length fix on the previous migration file,
picked up by `npm run lint --fix` while working on this commit.)
Explains the 422 mismatch behavior and the concrete cross-resource
replay scenario it closes, alongside the existing 409/TTL/cleanup
documentation.
@vercel

vercel Bot commented Aug 17, 2026

Copy link
Copy Markdown

@presidojay1 is attempting to deploy a commit to the chonilius' projects Team on Vercel.

A member of the Team first needs to authorize it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants