Skip to main content

Idempotency-key pattern for Api.External partner writes

A retried partner POST is guaranteed to happen eventually over a real network — see Outbound Resilience for the identical acceptance on the outbound side of this codebase. This is the corresponding guard on the receiving side, shipped 2026-08-25.

:::note Built ahead of any concrete write endpoint that needs it Api.External today only has OAuthController (api/oauth/token) and the read-only PingController — no partner write endpoint exists yet. Same "mechanism before the first concrete consumer" precedent as the domain-layer state-machine pattern and the draft/publish foundation: the next real partner write endpoint adopts this by adding one attribute, not by building the mechanism itself. :::

Storage

EOS_IDEMPOTENCY_KEYS — one row per (PartnerKey, IdempotencyKey) pair that completed with a non-5xx response. PartnerKey is the authenticated partner's username, read from the PartnerJwt sub claim the same dual-check way PingController already does. ResponseBody is the JSON-serialized action result, replayed verbatim on a redelivery of the same key with the same request. A brand-new Eos-owned table, hand-written outside Persistence/Scaffolded/ — same precedent as EOS_OUTBOX_EVENTS/ EOS_PROCESSED_DOMAIN_EVENTS.

IIdempotencyKeyStore/EfIdempotencyKeyStore exposes FindAsync (a plain lookup) and TryInsertAsync. The read isn't atomic with the later insert, so TryInsertAsync also catches the unique-constraint violation on (PartnerKey, IdempotencyKey) from a concurrent redelivery racing the first attempt, rather than trusting the earlier read alone.

Opting in: [IdempotentWrite]

[IdempotentWrite] (Api.External/Filters/IdempotentWriteAttribute.cs) is a pure marker attribute on a controller action, same metadata-only shape as RepositoryWriteStrategyAttribute. A future partner write action opts in by adding this one attribute; everything else below is already wired up.

IdempotencyActionFilter is a global IAsyncActionFilter, a no-op for every action without [IdempotentWrite]. For an opted-in action:

  1. Requires a non-empty Idempotency-Key request header — 400 if missing.
  2. Computes a request hash off the already-model-bound action arguments plus HTTP method/path (not the raw body stream, which is already consumed by model binding by the time an action filter runs).
  3. Looks up (partnerKey, idempotencyKey):
    • Not found → runs the action, then (only for a response under 500 — a 5xx is a transient failure that a legitimate retry should be able to re-attempt) persists the result.
    • Found, same request hash → replays the persisted response verbatim (Idempotency-Replayed: true response header) without re-running the action.
    • Found, different request hash409 — key reuse across different payloads is caller misuse, not a legitimate retry (mirrors Stripe's convention).
  4. An unhandled exception from the action is never persisted either, for the same "let a fixable failure be retried" reason as a 5xx.

:::caution Not built Any real partner write endpoint to attach [IdempotentWrite] to — this is genuinely ahead of need. Also no dedicated Api.External test project to exercise IdempotencyActionFilter directly (Api.External/Workers still have no WebApplicationFactory-style test project at all), so this mechanism is verified by dotnet build + manual reasoning only. :::

Reference files

IIdempotencyKeyStore.cs, IdempotencyRecord.cs, EfIdempotencyKeyStore.cs, EosIdempotencyKey.cs, EosIdempotencyKeyConfiguration.cs, IdempotentWriteAttribute.cs, IdempotencyActionFilter.cs, 0025_create-eos-idempotency-keys-table.sql, PartnerTokenIssuer.cs/ PingController.cs (the partner-identity claim convention this reuses).