Skip to content

feat: end-to-end Idempotency-Key support for fund/claim/release/refund endpoints - #35

Merged
chonilius merged 8 commits into
MergeFi:mainfrom
abayomicornelius:feat/idempotency-keys-mutation-endpoints
Jul 17, 2026
Merged

feat: end-to-end Idempotency-Key support for fund/claim/release/refund endpoints#35
chonilius merged 8 commits into
MergeFi:mainfrom
abayomicornelius:feat/idempotency-keys-mutation-endpoints

Conversation

@abayomicornelius

Copy link
Copy Markdown
Contributor

Summary

Closes #16. Adds an Idempotency-Key header contract to every fund/claim/release/refund mutation across Bounties, Escrow, Milestones, and MaintenancePool, so a client retry after a dropped connection can't double-execute a mutation.

  • IdempotencyKey entity + migration — new idempotency_keys table with a unique index on (key, scope, callerId). This unique index is the actual concurrency-safety primitive: IdempotencyInterceptor always attempts a plain insert() and reacts to a unique-violation rather than checking-then-inserting, since a JS-level "does a row exist?" check followed by a separate insert has the exact same TOCTOU race the issue's difficulty justification warns about — the DB constraint is what actually closes it.
  • @Idempotent(scope) decorator — applies IdempotencyInterceptor plus a Swagger @ApiHeader documenting the contract. Applied to BountiesController.fund/claim/refund, EscrowController.fund/release/splitRelease/refund, MilestonesController.fund/resolveIssue, MaintenancePoolController.deposit/assignReward.
  • Caching policy: 2xx and 4xx outcomes are cached and replayed verbatim on retry. A 5xx (or any non-HttpException failure) is not cached — the key is released instead — since forcing every future retry to replay a server error forever is worse than letting the retry run cleanly.
  • Concurrency: a request reusing a key that's still PROCESSING gets 409 Conflict. A PROCESSING row older than 30s is assumed to belong to a crashed request and is reclaimed via an atomic compare-and-swap (UPDATE ... WHERE id = :id AND status = 'processing' AND updatedAt = :observed), so two retries racing to reclaim the same stale row only let one through.
  • TTL/cleanup: completed keys are retained for 24h (IDEMPOTENCY_KEY_TTL_MS) and swept hourly by IdempotencyCleanupService (@nestjs/schedule). Documented in the README's new "Idempotency" section.
  • Caller scoping: none of the four target controllers currently sit behind JwtAuthGuard. resolveCallerId uses req.user.userId when present and falls back to a shared 'anonymous' bucket otherwise — documented as a known, accepted trade-off in the interceptor's doc comment rather than silently expanding scope by adding auth to these routes.

Test plan

  • IdempotencyInterceptor unit tests (idempotency.interceptor.spec.ts): missing/invalid header → 400; first call executes the handler and caches the result; duplicate key replays the cached result without re-executing (spy call count); two genuinely concurrent same-key requests interleaved via real Promise/microtask racing against an in-memory unique-index stand-in — only one executes, the other gets 409; different keys execute independently; 5xx is not cached (retry re-executes); 4xx is cached and replayed as the same exception; stale PROCESSING row is reclaimed instead of 409-ing forever; caller scoping via req.user.userId.
  • IdempotencyCleanupService unit tests: sweeps rows past expiresAt.
  • npx tsc --noEmit, npm run lint, npm run build all pass.
  • npx jest — all suites pass except the pre-existing *.integration.spec.ts files that require a live Postgres connection (not available in this environment; unrelated to this change).

🤖 Generated with Claude Code

One row per (Idempotency-Key header, endpoint scope, caller) tuple.
The unique index on (key, scope, callerId) is the actual concurrency
primitive: two simultaneous requests with the same key both attempt
an INSERT, the DB allows exactly one to win, and the loser reads back
the winner's row instead of racing into a duplicate execution — a
"check then insert" at the JS level would just relocate the same
TOCTOU race the issue's Difficulty Justification calls out, not close
it.

Status is PROCESSING while the underlying mutation is still running,
then COMPLETED with the cached response once it finishes (see the
interceptor, next commits, for exactly which outcomes get cached vs.
released for retry).
Matches IdempotencyKey exactly: the unique (key, scope, callerId)
index that makes concurrent-duplicate-request handling safe, and a
secondary index on expiresAt for IdempotencyCleanupService's periodic
sweep to use an index scan instead of a sequential one.
…or (MergeFi#16)

Response-caching + concurrency-safe replay engine for the Idempotency-Key
header: attempts an atomic insert into idempotency_keys and reacts to a
unique-violation instead of check-then-insert, closing the same TOCTOU race
the issue's difficulty justification warns about. Replays cached 2xx/4xx
outcomes verbatim; releases the key on 5xx so a retry re-executes cleanly.
Includes stale-PROCESSING-row reclaim (crash recovery) via an atomic
compare-and-swap update.
…ergeFi#16)

Global module so @idempotent resolves in every feature module without each
one importing it individually. IdempotencyCleanupService sweeps expired
rows hourly via @nestjs/schedule; retention itself is set per-row at claim
time (IDEMPOTENCY_KEY_TTL_MS) and plays no part in correctness — a replay
against an already-swept key just executes as new.
…points (MergeFi#16)

Guards BountiesController.fund/claim/refund, EscrowController.fund/release/
splitRelease/refund, MilestonesController.fund/resolveIssue, and
MaintenancePoolController.deposit/assignReward with the Idempotency-Key
header contract. splitRelease is included alongside release since it moves
funds through the same escrow in the same way.
…eFi#16)

Replace the query-builder based compare-and-swap with a plain
repo.update(criteria, partial) call — TypeORM ANDs multiple criteria
fields together, so this keeps the same atomic (id, status, updatedAt)
guard with far less code and no query-builder mocking needed in tests.
…ep (MergeFi#16)

IdempotencyInterceptor: duplicate key replays the cached result without
re-executing the handler; two genuinely concurrent same-key requests only
execute the handler once (one gets 409, proven with a real interleaved
race against an in-memory unique-index stand-in, not a mocked call count);
different keys execute independently; 5xx outcomes are released, not
cached, so a retry re-executes; 4xx outcomes are cached and replayed
verbatim; a stale PROCESSING row is reclaimed instead of 409-ing forever;
caller scoping keys by req.user.userId. IdempotencyCleanupService: sweeps
expired rows via LessThan(now()).

Also fixes escrow.controller.spec.ts, whose bare TestingModule now needs
IdempotencyInterceptor's dependencies resolvable since fund/release/
splitRelease/refund carry @idempotent.
Covers which endpoints require it, the 2xx/4xx-cached vs 5xx-released
caching policy, the 409 concurrent-duplicate behavior, and the 24h TTL /
hourly cleanup sweep.
@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

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

A member of the Team first needs to authorize it.

@chonilius
chonilius merged commit a941d45 into MergeFi:main Jul 17, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add end-to-end idempotency keys for all fund/claim/release/refund mutation endpoints to make client retries safe

2 participants