Repository navigation
feat(api): Idempotency-Key support on state-changing endpoints (+ recordPayment P2002 race fix) - #167
Merged
Levi-Ojukwu merged 4 commits intoOct 17, 2026
Conversation
IdempotencyKey model with a unique (scope, method, route, key) and an index on expiresAt. Forward-only migration generated with prisma migrate diff so it matches the schema exactly (CI's drift check). Refs OracleDesk#123
idempotent() implements draft-ietf-httpapi-idempotency-key-header on Postgres: ON CONFLICT DO NOTHING claim, replay with Idempotent-Replayed, 422 IDEMPOTENCY_KEY_MISMATCH, 409 IDEMPOTENCY_IN_PROGRESS + Retry-After, and conditional stale-lock takeover. Keys are scoped by user, method and route template. 2xx/4xx are stored (64 KB cap, header allowlist, sensitive fields redacted); 5xx releases the key. - required on POST /markets/generate (a retry returns the same jobId and starts no second cycle) and POST /trade/copy - optional on PATCH /trade/copy/:id/confirm, PUT /traces/access/allowance, POST /traces/verify and POST /traces/:id/unlock - /auth/* excluded (single-use handshake; /auth/verify returns a JWT) - CORS allows Idempotency-Key, exposes Idempotent-Replayed and Retry-After - hourly cron purges expired keys in batches of 5 000 Refs OracleDesk#123
A concurrent recordPayment with the same txHash passed the pre-check and lost the unique race on PaymentEvent.txHash inside $transaction (P2002), which surfaced as 500 INTERNAL_ERROR. Catch P2002, re-read the winning payment, and return its subscription for the same user or 409 PAYMENT_ALREADY_USED otherwise, with or without an Idempotency-Key. Refs OracleDesk#123
14 tests (in-memory store + real routes): replay, mismatch, in-flight 409, stale-lock takeover, 5xx not stored, 4xx replayed, per-user and per-route scope, header/secret redaction and size cap, optional/invalid keys, purge, required key on /trade/copy, /markets/generate replay starting one cycle, CORS, and the recordPayment P2002 race. docs/api.md gains an Idempotency section. Closes OracleDesk#123
|
@chonilius Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Implements
Idempotency-Keysupport (IETF draft-ietf-httpapi-idempotency-key-header, the Stripe model) on every state-changing endpoint, backed by Postgres. It also fixes therecordPaymentconcurrent-duplicate race, which returned a 500, whether or not a key is sent.Base branch:
develop, as the issue requires.Why
Retries are routine (flaky mobile connections, React Query mutation retries, restored tabs), and today a retry repeats the side effect:
POST /trade/copy→ anotherPENDINGCopyTradeper retry.POST /markets/generate→ anotherrunMarketMakerCycle(): more paid LLM calls and, in live mode, another treasury-funded market.POST /traces/:id/unlock: two concurrent requests with the sametxHashboth pass the pre-check, and the loser hits thePaymentEvent.txHashunique index (P2002) → 500INTERNAL_ERROR.What changed
1.
feat(db):idempotency_keystableIdempotencyKeymodel exactly as specified in the issue: unique(scope, method, route, key), index onexpiresAt,@@map("idempotency_keys").20261017000008_idempotency_keys, generated withprisma migrate difffrom the previous schema, so it matches the datamodel byte-for-byte for CI'smigrate diff --exit-codedrift check.2.
feat(api):idempotent()middleware (src/middlewares/idempotency.middleware.ts)required400 IDEMPOTENCY_KEY_REQUIRED400 IDEMPOTENCY_KEY_INVALIDINSERT … ON CONFLICT DO NOTHING RETURNING id, runs the handler, stores the final responseIdempotent-Replayed: true; handler not run422 IDEMPOTENCY_KEY_MISMATCH409 IDEMPOTENCY_IN_PROGRESS,Retry-After: 1UPDATE … WHERE lockedUntil < now()takes over; only one retry can win409 IDEMPOTENCY_RESPONSE_UNAVAILABLE(req.user.userId ?? 'anon', method, route template, key). The route template isreq.baseUrl + req.route.path(e.g./api/v1/trade/copy/:id/confirm), so the same key on another route or from another user never collides.sha256Hex(canonicalJson({ method, route, params, query, body })), reusingsrc/utils/hash.util.ts. The parsedreq.queryis used andcanonicalJsonsorts keys, so JSON key order and query-parameter order don't change it.res.json(every handler anderrorMiddlewareend insendSuccess/sendError→res.status().json()). The record is written onres.on('finish').Content-Type,Location,API-Version);Set-Cookie/Authorizationare never stored. Body values under keys matchingtoken|secret|authorization|password|private keyare redacted before storage, as defence in depth like the pino redact list. Bodies are capped at 64 KB.POST /traces/verify's 20 s IPFS timeout.ON CONFLICTinsert is the only synchronization point (no in-memory map), so this is safe across multiple instances. All SQL is raw ($queryRaw/$executeRaw), behind a smallIdempotencyStoreinterface with asetIdempotencyStore()test seam (same pattern assetTransactionReader).Routes
POST /markets/generatejobId, so a retry returns the same job and starts no second cycle.POST /trade/copyPATCH /trade/copy/:id/confirm,PUT /traces/access/allowance,POST /traces/verify,POST /traces/:id/unlockPOST /auth/challenge,POST /auth/verify/auth/verifyreturns a JWT that must never be storedThe middleware runs after
requireAuth/requireAdmin(so it can scope by user) and afterexpress.json().CORS (
src/app.ts):allowedHeaders: ['Content-Type', 'Authorization', 'Idempotency-Key']andexposedHeaders: ['Idempotent-Replayed', 'Retry-After']. Note this replaces thecorsdefault (reflecting whatever the browser requests) with an explicit list. I greppedsrc/: the only custom request header read isAuthorization, so nothing the app uses is dropped.Cleanup (
src/cron/monitor.cron.ts):10 * * * *runspurgeExpiredIdempotencyKeys(), deleting expired rows in batches of 5 000 (capped at 100 batches per run) and logging the count.3.
fix(payments):recordPaymentrace (independent of the header)In
src/services/subscription.service.ts, P2002 from the$transactionis caught, the winningpaymentEventis re-read bytxHash, and the call either returns the existing subscription (same user) or throws409 PAYMENT_ALREADY_USED(another user). A concurrent duplicate without a key no longer 500s. Behaviour is otherwise unchanged.4.
test,docsdocs/api.md: new Idempotency section covering the route table, key format, scope, TTL, fingerprint, the full response/error table, 5xx and 4xx-replay semantics ("use a new key after changing state"), the redaction/header policy, and a React Query snippet (crypto.randomUUID()per action, reused across retries).tests/idempotency.test.ts: 14 tests with an in-memory store, covering every case in the issue's testing plan:replay +
Idempotent-Replayed(including JSON key order not mattering) · mismatch 422 · in-flight 409 +Retry-After· crashed-lock takeover · 5xx not stored / retry re-executes · 4xx stored and replayed · per-user and per-route scope (and a different:id→ 422) · no auth/cookie headers stored, secrets redacted, > 64 KB not replayed · optional/invalid keys · purge deletes only expired rows ·POST /trade/copywithout a key → 400 (real app) ·POST /markets/generatereplay returns the samejobIdandrunMarketMakerCycleruns once (real app, cycle monkeypatched) · CORS preflight and exposed headers ·recordPaymentsimulated P2002 → existing subscription / 409.Testing
On Node 22 (the
.nvmrc/ CI version):Full suite on Node 22, before vs after:
develop(baseline)The same 5 files fail in both (
audit-constraints,constraints,copy-trade-verify,resilience,trace-audit). They're pre-existing and unrelated: the constraint suites need a live Postgres, and the others match the type errors below.develop(not introduced here)npm run build/npm testalready fail ondevelop:tscreports 16 type errors (incopy-trade.service.ts,monitor.cron.ts's existingsweepCopyTrades,stellar/clients.ts,subscription.service.ts:265SettlementCurrency,trace-audit.service.ts,tests/copy-trade-verify.test.ts,tests/resilience.test.ts). The set is identical with and without this PR (diffed). Becausetsconfig.test.jsonsetsnoEmitOnError: true,npm testcan't compile ondevelopat all. I ran the suites by compiling with--noEmitOnError false.src/middlewares/abort.middleware.ts(req.signal = controller.signal) throwsCannot set property signal of #<IncomingMessage> which has only a getter, so every request 500s (the existinghttp.testfails 5/7 there too). CI is on Node 22, so it's unaffected today, but it'll break on a Node upgrade. Worth a separate issue.Not in scope (per the issue's non-goals)
Making the background market-maker cycle itself resumable, and idempotency for agent chain writes. This PR only guarantees that one key starts at most one generation job.
Closes #123
Closes #120
Closes #121
Closes #122