Skip to content

feat(idempotency): production-grade idempotency across http, mediator, messaging and ef core - #155

Merged
senrecep merged 79 commits into
mainfrom
feat/idempotency-core
Oct 20, 2026
Merged

senrecep merged 79 commits into
mainfrom
feat/idempotency-core

Conversation

@senrecep

@senrecep senrecep commented Oct 20, 2026 •

Copy link
Copy Markdown
Owner

Summary

Production-grade idempotency across HTTP, Mediator, messaging and EF Core, per plans/2026-10-19-idempotency-PLAN.md.

  • Core (CSharpEssentials.Idempotency)
    • Factory-only IdempotencyScope (User, Tenant, Client, UserAndTenant, Custom, Global).
    • Length-prefixed idem:v3: storage keys.
    • IIdempotencyExecutor with profiles.
    • Message inbox (IdempotencyKeys.FromMessage).
    • Off/Observe/Enforce modes through IOptionsMonitor.
    • Metrics (idempotency.decisions) and EventIds 7001–7017. Logs carry hashed keys only.
  • AspNetCore
    • Release policy for 5xx and exceptions.
    • Problem codes: KeyReused 422, InFlight 409 + Retry-After, 400 for the rest.
    • Structured-field keys and the new IdempotencyKeyHeader.TryParse, which the middleware also uses.
  • Mediator: IdempotencyBehavior with retention based on the failure kind (FailureRetention).
  • OpenAPI and Swashbuckle: idempotency conventions, documented according to the mode.
  • Http: SetIdempotencyKey, IdempotencyKeyHandler, and IdempotentRetry that honours Retry-After (capped by MaxRetryAfter).
  • EF Core: transactional EfCoreIdempotencyRecordStore.
    • Primary key KeyHash binary(32) with an ordinal re-check, safe under case-insensitive and PAD SPACE collations.
    • Binary collation per provider.
  • Testing: IdempotencyRecordStoreContractTests contract kit for custom stores.
  • Examples: Redis store (Lua) and an end-to-end example: client retry → API → Mediator on the EF store with the payments profile → inbox → webhook. Both run in CI.
  • Benchmarks: middleware overhead and fingerprint buffering by body size.
  • Docs: Readme sections, docs/migration/v6-to-v7-idempotency.md, API_REFERENCE.md, agent skills.

Breaking changes (v7)

  • IdempotencyOptions is resolved as transient CurrentValue.
  • Scopes can only be built through the factories.
  • AllowUnscopedKeys → UnscopedKeyBehavior.
  • The record format is idem:v3:; 6.x entries are not read. Drain before a rolling deploy.

See the migration guide.

New dependencies

  • Microsoft.Extensions.Options [9.0.4,); EF projects pin it with VersionOverride [10.0.1,).
  • StackExchange.Redis 2.8.31, examples only.

Known gaps

  • The webhook's 5xx path is not tested in the example.
  • The example consumer has no limit on poison-message retries; its docs say so.
  • Benchmark results are not committed.
  • There is no Redis benchmark.

Test plan

  • dotnet build --no-incremental: 0 errors, the 7 known warnings
  • CSharpEssentials.Tests: 8121/8121
  • Examples.Idempotency.EndToEnd: 14/14 (Postgres via Testcontainers)
  • Examples.Idempotency.Redis: 28/28
  • Benchmarks: --job Dry passes on net9.0, net10.0 and net11.0
  • Two independent code reviews; findings fixed and the second review approved

BEGIN_COMMIT_OVERRIDE
feat(idempotency)!: production-grade idempotency across http, mediator, messaging and ef core

BREAKING CHANGE: idempotency moves to the new CSharpEssentials.Idempotency core; see the migration guide.

  • IIdempotencyStore, IdempotencyReservation, IdempotencyReservationStatus, InMemoryIdempotencyStore and DistributedCacheIdempotencyStore are removed from CSharpEssentials.AspNetCore; use IIdempotencyRecordStore, InMemoryIdempotencyRecordStore and DistributedCacheIdempotencyRecordStore.
  • IdempotencyOptions.KeyScope and DefaultKeyScope are removed; use ScopeResolver with IdempotencyScopes. Scopes are built only through the IdempotencyScope factories, and AllowUnscopedKeys is replaced by UnscopedKeyBehavior.
  • IdempotencyOptions.ShouldStore is a Func<IdempotencyOutcome, bool> instead of a Func<int, bool>; IdempotencyStorePolicies.SuccessOnly restores the 6.x rule.
  • IdempotencyOptions.RetryAfter is a non-nullable TimeSpan; the 409 always carries Retry-After.
  • A 5xx after the handler started is stored for ServerErrorRetention; 503, 504, exceptions and aborts after the handler started keep the key in flight until InFlightTimeout instead of releasing it.
  • A key must be printable ASCII other than space, " and \; other keys get 400 Idempotency.KeyInvalid.
  • IdempotencyOptions is resolved as a transient CurrentValue, and records use the idem:v3: storage key format, so 6.x entries are not read.

END_COMMIT_OVERRIDE

Generated with Python's standard library so the expected keys and UUIDv5
values come from an implementation independent of the library under
test. Adds idempotency to the allowed commit scopes.
…tract kit

Add CSharpEssentials.Idempotency (net11.0, net10.0, net9.0, netstandard2.1):
IIdempotencyRecordStore with reservation tokens and opaque payloads,
InMemoryIdempotencyRecordStore (atomic in one process) and
DistributedCacheIdempotencyRecordStore (best effort), IdempotencyRecordKey
with a length-prefixed v3 storage key and an explicit global scope,
IdempotencyScopes resolvers (User, Tenant, UserAndTenant, Client, Custom,
Global) and IdempotencyKeys (For, FromMessage, RFC 9562 UUIDv5, IsValid)
pinned by tests/vectors/idempotency-key-vectors.json.

Add CSharpEssentials.Idempotency.Testing, an xUnit contract test base class
for custom stores. Wire both into the solution, central package versions,
the publish script, README, AGENTS.md and the API reference.
…y core

The Idempotency-Key middleware now stores its responses through
IIdempotencyRecordStore from CSharpEssentials.Idempotency. Records use the
new Namespace option (default "http") and a scope from the new
ScopeResolver option (default IdempotencyScopes.User). AllowUnscopedKeys
keeps its meaning: when true, an unresolved scope uses a global key.
Stored responses use serializer version 3; an unreadable completed payload
returns 500 without running the handler again.

BREAKING CHANGE: IIdempotencyStore, IdempotencyReservation,
IdempotencyReservationStatus (CSharpEssentials.AspNetCore),
InMemoryIdempotencyStore, DistributedCacheIdempotencyStore,
IdempotencyOptions.KeyScope and IdempotencyOptions.DefaultKeyScope are
removed without an obsolete bridge. Use IIdempotencyRecordStore,
IdempotencyRecordReservation, IdempotencyReservationStatus,
InMemoryIdempotencyRecordStore, DistributedCacheIdempotencyRecordStore and
IdempotencyOptions.ScopeResolver from CSharpEssentials.Idempotency.
Storage keys change to the idem:v3: format and entries written by earlier
versions (v2) are no longer read. See plan section 10 R9 for the mapping.
…erministic keys

Run the contract kit against the in-memory and distributed cache stores, add store-specific cases (sweep, versioned entries, storage key prefix), and check scope resolvers, record key encoding and IdempotencyKeys against the key vectors.
Map the removed AspNetCore store types and KeyScope to the CSharpEssentials.Idempotency API, show how a custom store moves to IIdempotencyRecordStore and the contract kit, and describe the v3 record format and rolling deploy risk (plan section 10 R9).
Version 2 pins the scope strings (user, tenant, client, custom, global and
userAndTenant) and the length-prefixed storage key under idem:v3: so every
implementation builds identical records.
…em codes

- accept an Idempotency-Key sent as a structured-field string ("abc") and
  store it under the same record as the bare value
- validate the unquoted key with IdempotencyKeys.IsValid; malformed quoting,
  invalid characters, an empty value or several headers return 400
  Idempotency.KeyInvalid
- every idempotency problem (KeyMissing, KeyInvalid, InFlight, KeyReused) is
  application/problem+json built from an Error, with a top-level code member
  and errorCodes; no idempotency type URI is published
- the 409 Idempotency.InFlight always sends Retry-After

BREAKING CHANGE: IdempotencyOptions.RetryAfter is a non-nullable TimeSpan
(default 1 second, must be positive); the header can no longer be omitted.
Keys are limited to printable ASCII without space, '"' and '\', so keys
with other characters that were accepted before now return 400.
…rted

Idempotent endpoints now get an execution filter that marks the handler as
started: .WithIdempotency() adds an endpoint filter on Minimal API endpoints
and groups, and [Idempotent] is the action filter on MVC actions. The
middleware uses that mark to decide what happens to the key:

- the handler did not start (rate limiter, load shedding, auth, binding or
  model validation failure): the key is released;
- the status is in NeverStoreStatusCodes (503, 504 by default), or the request
  failed or was aborted after the handler started: the key stays in flight
  until its lease expires, so a retry gets 409 and runs once more afterwards;
- otherwise ShouldStore decides. IdempotencyStorePolicies.Default stores 2xx,
  and 3xx and 5xx from a started handler; 5xx is kept for
  ServerErrorRetention (10 minutes). SuccessOnly stores 2xx only and
  releases 3xx and 5xx.

A response that cannot be stored (handler-set Content-Encoding,
text/event-stream, larger than MaxResponseBodySize) releases the key only on
4xx and stays in flight otherwise.

Unscoped keys follow UnscopedKeyBehavior: Skip (default, logs
Idempotency.UnscopedKeySkipped), Reject (400 Idempotency.ScopeRequired) or
Global. Retry-After is replayed by default. An endpoint without the filter
logs a warning once, or throws under RequireExecutionFilter, and a request
timeout longer than InFlightTimeout logs a warning once per endpoint.
Startup validation checks InFlightTimeout against RetentionPeriod and
MaxInFlightTimeout (15 minutes), ServerErrorRetention against
RetentionPeriod, and NeverStoreStatusCodes.

BREAKING CHANGE: AllowUnscopedKeys is replaced by UnscopedKeyBehavior, and
an unscoped request now logs a warning. ShouldStore takes an
IdempotencyOutcome instead of a status code. 3xx and handler 5xx responses
are stored, 503 and 504 keep the key in flight, an exception or abort after
the handler started keeps the key in flight instead of releasing it, and a
response that cannot be stored no longer releases the key unless it is 4xx.
Retry-After is added to ReplayedHeaders. Idempotent endpoints need
.WithIdempotency() or [Idempotent] for the handler-started mark; without it
every outcome releases the key. New startup validation rejects
InFlightTimeout above RetentionPeriod or MaxInFlightTimeout,
ServerErrorRetention outside (0, RetentionPeriod] and an empty or
out-of-range NeverStoreStatusCodes.
…options

Describe the outcome table, ServerErrorRetention, NeverStoreStatusCodes, IdempotencyStorePolicies, UnscopedKeyBehavior, the execution filter, Retry-After replay and the new startup validation, and add the behavior changes and option mappings to the 6.x to 7.0 migration guide.
…ut the idempotency filter

An idempotent endpoint without the execution filter cannot report whether its handler started. Its 2xx responses are now stored as before, and a 2xx that cannot be stored keeps the key in flight; 3xx, 4xx, 5xx (503 and 504 included) and exceptions before the response started release the key. ShouldStore receives the outcome with HandlerStarted false. The warning and RequireExecutionFilter are unchanged.
…torage keys

Scope strings now carry their kind (user:, tenant:, client:, {kind}: for custom scopes), UserAndTenant length-prefixes only the tenant part, and a global key writes its scope part as "global" instead of "-". The scope and storage key vectors are consumed by the tests.

BREAKING CHANGE: IdempotencyScopes.Custom takes a scope kind; scope strings carry user:/tenant:/client: prefixes and global keys store the scope as "global".
…hare global records

A global key writes its scope part as 'global', so an owner scope with that raw
value would have reached the global records. IdempotencyScope.Of and
IdempotencyRecordKey now reject it.
IdempotencyScope is now created only by User, Tenant, Client,
UserAndTenant, Custom(kind, value) and Global. Every owner scope carries
its kind, so Tenant("user:42") is tenant:user:42 and never matches
User("42"), and no owner scope can be the reserved global scope part.
The storage key format and the key vectors are unchanged.

BREAKING CHANGE: IdempotencyScope.Of is internal. IdempotencyRecordKey
takes an IdempotencyScope? (null or Global gives a global key); the
constructor taking a raw scope string is no longer public.
IdempotencyScope.Custom and IdempotencyScopes.Custom reject a kind that
contains ':' or consists only of digits. The protected Key helper of
IdempotencyRecordStoreContractTests takes an IdempotencyScope?.
The scope factories (User, Tenant, Client, UserAndTenant, Custom kind and
value) and the IdempotencyRecordKey constructor (namespace, scope, key) now
throw ArgumentException for an unpaired surrogate, so an invalid value fails
at construction instead of in ToStorageKey. HTTP keys already reject it as
Idempotency.KeyInvalid; a test covers bare and quoted header values.

Custom kinds (IdempotencyScope.Custom and IdempotencyScopes.Custom) also
reject '/' and the reserved built-in kinds user, tenant, client and global
in any casing.

Document that stores must compare Namespace, Scope and Key ordinally and
case-sensitively, and add a contract scenario for case isolation.
IIdempotencyExecutor reserves a key, replays a stored result, reports an
in-flight or reused key, and runs the operation at most once. A key is
released when the operation never started, stays in flight when it threw
after starting, and is completed with the codec's classification
(Store, StoreAsFailure, Release) otherwise. Modes Off, Observe and Enforce;
settings resolve operation > profile > defaults with a built-in
"messaging" profile; AddIdempotencyExecutor validates them at startup.
IdempotencyInbox runs a message handler at most once per consumer: the
namespace is messaging:{consumer}, the key is IdempotencyKeys.FromMessage
and the fingerprint is constant because the message id is authoritative.
It uses the messaging profile (7-day retention) unless the consumer names
another profile, and AddIdempotencyExecutor registers it.
IdempotencyExecutorOptions.ResolveMode returns the mode an operation runs in (operation, else profile, else default), so a caller can skip key and fingerprint work in Off and decide whether a preparation problem blocks it in Enforce or is only logged in Observe.
IdempotencyBehavior runs an IIdempotentRequest at most once per key and
caller through IIdempotencyExecutor and replays the stored response to a
retry. Scopes come from the request or from IdempotencyScope factories via
a resolver fed by IIdempotencyScopeAccessor.

A failed Result whose first error is Unexpected is kept for the failure
retention; any other failure is a business failure and is kept as long as
a success. A failed ITransactionalRequest releases its key.

AddMediatorIdempotencyBehavior places the behavior outside the transaction
(before the lock) or right inside it, regardless of registration order.
Client retry with an Idempotency-Key, a WithIdempotency endpoint, Mediator commands on the EF Core
store under the payments profile, a message inbox and a signed provider webhook, tested against
PostgreSQL through Testcontainers.
…e's key normalization

The middleware unquotes a structured-field Idempotency-Key before validating it.
A command that read the raw header saw the quotes and rejected the key. The
middleware now parses through the same public method, so both layers see one key.
…to a command

Document IdempotencyKeyHeader.TryParse in the API reference and the agent skill,
and parse the header in the transactional money movement sample so a quoted key
reaches the command as the middleware saw it.
…and reload the payments mode

- Parse Idempotency-Key with IdempotencyKeyHeader.TryParse so a quoted key is one key in both layers
- Send the payment provider IdempotencyKeys.For("charge", merchant, key); the providers dedupe on it
- Register a ConfigurationChangeTokenSource so a reload switches the payments mode without a restart
- Answer a failed webhook with 500 and scope settlements to Custom("account", ...)
- Compare webhook signatures as decoded HMAC bytes
- Redeliver in-flight and failed messages after a delay instead of in a hot loop
- Dispose the host's clients, release the held charge in finally, check Build() in the client
- Move PostgresCollection to its own file
- Cover the quoted key, a lost provider response, KeyReused from the database layer and the mode reload
…er the raw body and answer failures with a 5xx
@socket-security

socket-security Bot commented Oct 20, 2026 •

Copy link
Copy Markdown

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addedstackexchange.redis@​2.8.319110090100100

View full report

@senrecep
senrecep merged commit 42287de into main Oct 20, 2026
10 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.

1 participant