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:
- Requires a non-empty
Idempotency-Keyrequest header —400if missing. - 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).
- Looks up
(partnerKey, idempotencyKey):- Not found → runs the action, then (only for a response under
500— a5xxis 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: trueresponse header) without re-running the action. - Found, different request hash →
409— key reuse across different payloads is caller misuse, not a legitimate retry (mirrors Stripe's convention).
- Not found → runs the action, then (only for a response under
- 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).