Skip to content

feat(idempotency): durable, payload-bound idempotency for postage mutations - #1903

Merged
kryputh merged 3 commits into
Stellar-Mail:mainfrom
Nanle-code:feat/durable-idempotency-storage-1498
Jul 29, 2026
Merged

feat(idempotency): durable, payload-bound idempotency for postage mutations#1903
kryputh merged 3 commits into
Stellar-Mail:mainfrom
Nanle-code:feat/durable-idempotency-storage-1498

Conversation

@Nanle-code

Copy link
Copy Markdown
Contributor

Summary

Idempotency records were already persisted durably (Durable Object storage,
surviving restarts and shared across Worker instances), but the key hash
only bound to actor:rawKey — not the request payload, method, or route.
A reused key with a different payload would silently replay the wrong
cached response instead of failing closed, and refund had no idempotency
support at all despite being as payment-critical as settle.

  • Bind every lease/response to a canonical digest of the request payload;
    a reused key with a different payload now returns 409 idempotency_mismatch
    instead of an incorrect replay or block
  • Scope the key hash by actor + HTTP method + route template, not actor alone
  • Add withIdempotency(), a single entry point that acquires the lease,
    replays completed records, records terminal outcomes, and — critically —
    releases the lease on an uncached transient failure, so a legitimate
    retry isn't blocked for the full lease TTL
  • Wire postage submit and settle onto the new helper, and add the same
    idempotency support to postage refund, which previously had none
  • Bump the idempotencyRecord schema to v2 with a migration for legacy
    records predating requestDigest

Closes #1498

Acceptance criteria

  • Duplicate identical requests replay the stored response
  • A reused key with a different payload returns 409
  • Concurrent duplicates execute the operation once
  • Behavior survives independent API contexts (Durable Object storage,
    verified with two independent coordinator instances sharing storage)

Test plan

  • npx vitest run tests/unit — 1688 passing, 3 pre-existing expected-fail
  • npx tsc --noEmit — clean
  • npx eslint on all changed files — clean
  • New coverage: digest-mismatch conflicts, concurrent-duplicate races,
    transient-failure lease release, and route-level tests for settle/refund

…e keys by method/route

Idempotency records already persisted durably via Durable Object storage
(surviving restarts and shared across Worker instances), but the same raw
key was only hashed with the actor, not the method/route, and carried no
binding to the request payload. A reused key with a different payload
silently replayed (or blocked behind) the wrong response instead of
failing closed.

- Store a canonical request-body digest on every idempotency record and
  compare it on acquire; a mismatch now returns 409 idempotency_mismatch
  instead of an incorrect replay or block.
- Scope the key hash by actor + HTTP method + route template, not actor
  alone, so the same client key can't collide across endpoints.
- Add withIdempotency(), a single entry point that acquires the lease,
  replays completed records, and releases the lease on uncached
  (transient) failures so an immediate retry isn't blocked for the full
  lease TTL.
- Wire the postage submit and settle routes onto the new helper, and add
  the same idempotency support to postage refund, which previously had
  none.
- Bump the idempotencyRecord schema to v2 with a migration for legacy
  records predating requestDigest.
@kryputh
kryputh merged commit 0edd429 into Stellar-Mail:main Jul 29, 2026
5 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.

Implement durable idempotency storage for all mutating routes

2 participants