Ana içeriğe geç

OrionOnce

OrionOnce

CI/CD NuGet

HTTP idempotency for ASP.NET Core. A client sends an Idempotency-Key with a request; if the same key arrives again, OrionOnce replays the first response instead of running your handler a second time. Retries stop double-charging, double-shipping, and double-posting.

Part of the Orion family. Usable entirely on its own.

Why​

Networks drop responses, clients retry, and load balancers replay. Without idempotency a retried POST /payments charges twice. The fix is well understood (key the request, cache the response, replay on repeat) but fiddly to get right: you have to buffer the body, detect a key reused for a different request, reject duplicates that are still in flight, and avoid caching transient failures. OrionOnce does those four things.

Features​

  • Idempotency-Key middleware that runs your handler once per key and replays the captured response on every retry, marked with an Idempotency-Replayed: true header.
  • Request fingerprinting via SHA-256 over method, path (with query), and body, so a key reused for a different request is detected and rejected instead of silently replayed.
  • In-flight protection: a duplicate that arrives while the first request is still running gets 409 Conflict rather than a second execution.
  • No caching of transient failures: a handler exception or a 5xx response releases the key, so the client can safely retry it.
  • Body-size guard: bodies larger than MaxBodyBytes are rejected with 413 before any work.
  • Pluggable storage behind IIdempotencyStore; ships with an in-process InMemoryIdempotencyStore and lets you swap in a shared store for multi-instance deployments.
  • Capture-and-replay outside HTTP via IdempotentExecutor: run an operation once per key and replay its captured typed result on later duplicate calls, over the same IIdempotencyStore.
  • Retention housekeeping via IIdempotencyStore.SweepAsync, which purges expired entries that were acquired but never seen again, plus a TimeProvider-based clock for testable expiry.
  • OpenTelemetry metrics through a Moongazing.OrionOnce meter with an outcome-tagged counter.
  • Multi-targeted for net8.0, net9.0, and net10.0, nullable-enabled, warnings-as-errors.

Install​

dotnet add package OrionOnce

The package id is OrionOnce; the root namespace is Moongazing.OrionOnce.

Quick start​

Register the services, then add the middleware after routing and before your endpoints.

using Moongazing.OrionOnce;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddOrionOnce(o =>
{
o.Retention = TimeSpan.FromHours(24);
o.RequireKey = false; // set true to make the key mandatory on guarded methods
});

var app = builder.Build();

app.UseRouting();
app.UseOrionOnce(); // after routing, before your endpoints
app.MapControllers();

app.Run();

A client then retries safely:

POST /payments
Idempotency-Key: 3f1c9b8a-...

# first call -> handler runs, 201 created, response cached
# retry -> handler skipped, the same 201 replayed with "Idempotency-Replayed: true"

Only POST, PUT, PATCH, and DELETE are guarded by default; safe methods (GET, HEAD, OPTIONS) pass through untouched because they need no protection.

Usage​

Conflict and replay behaviour​

The middleware resolves every guarded request carrying a key to exactly one of these outcomes:

SituationResult
Key not seen beforeHandler runs once; its response is cached
Same key, same request, already completedStored response replayed, Idempotency-Replayed: true
Same key, request still in flight409 Conflict (no second execution)
Same key, different request body422 Unprocessable Entity
Guarded method, no key, RequireKey = true400 Bad Request
Guarded method, no key, RequireKey = falseBypassed; handled normally
Handler throws or returns 5xxKey released, not cached, so the client can retry
Body larger than MaxBodyBytes413 Payload Too Large

Fingerprint scope​

A key alone does not identify a request. OrionOnce binds each key to a fingerprint computed by RequestFingerprint.Compute, a SHA-256 over the uppercased method, the path plus query string, and the body bytes. Two requests that present the same key must produce the same fingerprint; otherwise the second is rejected with 422. The fingerprint is exposed as a static helper if you need to compute it yourself:

using Moongazing.OrionOnce;

string fingerprint = RequestFingerprint.Compute("POST", "/orders?expedite=1", bodyBytes);
// 64-character lowercase hex SHA-256 digest

Custom store​

The default store is process-local. For a deployment with more than one instance, implement IIdempotencyStore over a shared backend (Redis, SQL, etc.) and register it before or after AddOrionOnce(). The library only adds the in-memory store when none is present, so your registration wins.

using Moongazing.OrionOnce.Storage;

public sealed class RedisIdempotencyStore : IIdempotencyStore
{
public Task<IdempotencyLease> AcquireAsync(
string key, string fingerprint, CancellationToken cancellationToken = default)
{
// Must be atomic: two concurrent requests with the same key must not both
// receive IdempotencyOutcome.Acquired. Return:
// IdempotencyLease.Completed(response) when the key already finished,
// the in-progress outcome while another request holds the key,
// the mismatch outcome when the stored fingerprint differs,
// the acquired outcome when this caller claims the key.
throw new NotImplementedException();
}

public Task CompleteAsync(
string key, CachedResponse response, CancellationToken cancellationToken = default) =>
// Persist the captured response so later requests replay it.
throw new NotImplementedException();

public Task ReleaseAsync(string key, CancellationToken cancellationToken = default) =>
// Drop a still-in-progress claim so the request can be retried.
throw new NotImplementedException();
}
builder.Services.AddSingleton<IIdempotencyStore>(new RedisIdempotencyStore(/* ... */));
builder.Services.AddOrionOnce();

AcquireAsync must be atomic so two concurrent requests with the same key cannot both be told to proceed. The IdempotencyLease factory members (IdempotencyLease.Acquired, .InProgress, .FingerprintMismatch, and .Completed(response)) are public, so a store in your own assembly can return them directly. See docs/FEATURES.md for the full store contract and lifecycle.

Durable EF Core store​

For a multi-instance deployment you do not have to hand-write the store. The OrionOnce.EntityFrameworkCore package (namespace Moongazing.OrionOnce.EntityFrameworkCore) provides a durable IIdempotencyStore over EF Core that persists keys, leases, and captured responses in your database, so a retry that lands on a different instance still replays the first response. AcquireAsync is atomic through the key's primary-key unique constraint (the first caller's insert wins; a concurrent second insert is rejected and resolves to in-flight or completed), and SweepAsync bulk-deletes expired rows with ExecuteDeleteAsync.

dotnet add package OrionOnce.EntityFrameworkCore

The package references Microsoft.EntityFrameworkCore.Relational only, so you choose the database provider:

using Moongazing.OrionOnce.EntityFrameworkCore;

builder.Services.AddOrionOnce(o => o.Retention = TimeSpan.FromHours(24));

// Registers an IDbContextFactory<OrionOnceDbContext> and uses the EF Core store as the
// IIdempotencyStore. Pick the provider here; the retention window is read from AddOrionOnce.
builder.Services.AddOrionOnceEntityFrameworkCoreStore(
o => o.UseNpgsql(builder.Configuration.GetConnectionString("OrionOnce")));

Create the OrionOnceIdempotencyEntries table with an EF Core migration (or apply IdempotencyEntryConfiguration to fold the entry into a context you already own). The store pins one EF Core major per target framework (8.0.x on net8.0, 9.0.x on net9.0, 10.0.x on net10.0).

Idempotent execution outside HTTP​

Not every duplicate arrives as an HTTP request. A retried message-queue delivery, a re-invoked background job, or a repeated RPC needs the same guarantee: run the operation once, replay its result the next time. IdempotentExecutor provides that over the same IIdempotencyStore. The first call runs the operation and captures its typed result; a later call with the same key and fingerprint replays the stored result without running the operation again.

The library ships no serializer, so you supply a codec that turns the result into bytes and back. DelegateResultCodec<TResult> wraps a serialize and a deserialize delegate inline, here with System.Text.Json:

using System.Text.Json;
using Moongazing.OrionOnce;
using Moongazing.OrionOnce.Storage;

var store = new InMemoryIdempotencyStore(TimeSpan.FromHours(24));
var executor = new IdempotentExecutor(store);

var codec = new DelegateResultCodec<Receipt>(
serialize: receipt => JsonSerializer.SerializeToUtf8Bytes(receipt),
deserialize: payload => JsonSerializer.Deserialize<Receipt>(payload)!,
contentType: "application/json");

string key = message.IdempotencyKey;
string fingerprint = RequestFingerprint.Compute("charge", message.OrderId, message.Body);

Receipt receipt = await executor.ExecuteAsync(
key,
fingerprint,
ct => ChargeAsync(message, ct), // runs at most once per key
codec,
cancellationToken);

The outcomes mirror the middleware. A completed key replays its stored result. A key still held by a concurrent caller, or reused with a different fingerprint, throws IdempotentExecutionException carrying the IdempotencyOutcome (InProgress or FingerprintMismatch) so you can map it to a retry or a rejection. If the operation throws, or its result cannot be captured, the key is released so the call can be retried, and the original exception propagates unchanged.

Reclaiming expired entries​

InMemoryIdempotencyStore evicts expired entries lazily on access, so a key that is acquired and never touched again holds its memory until the retention window is swept. SweepAsync removes every entry whose window has elapsed and returns how many it removed; run it periodically (for example from a BackgroundService) to reclaim that memory:

int removed = await store.SweepAsync(cancellationToken);

The default interface implementation is a no-op returning zero, which suits stores such as Redis that expire entries themselves. For testable expiry, the in-memory store also accepts a TimeProvider, so a fake clock can drive entries past their window in a unit test:

var store = new InMemoryIdempotencyStore(TimeSpan.FromMinutes(5), timeProvider);

Configuration​

AddOrionOnce takes an optional Action<IdempotencyOptions>. The options are validated at registration (HeaderName must be non-empty; Retention and MaxBodyBytes must be positive).

OptionTypeDefaultPurpose
HeaderNamestringIdempotency-KeyThe request header that carries the key
RetentionTimeSpan24 hoursHow long a captured response is retained for replay
MethodsISet<string>POST, PUT, PATCH, DELETEThe HTTP methods that are guarded (case-insensitive)
RequireKeyboolfalseWhen true, a guarded request with no key is rejected 400; when false, it bypasses idempotency
MaxBodyBytesint1 MiBLargest buffered request body; larger bodies are rejected 413
builder.Services.AddOrionOnce(o =>
{
o.HeaderName = "Idempotency-Key";
o.Retention = TimeSpan.FromHours(6);
o.RequireKey = true;
o.MaxBodyBytes = 256 * 1024;
o.Methods.Add("POST"); // Methods is a mutable set; adjust to taste
});

Telemetry​

OrionOnce publishes metrics through IdempotencyDiagnostics, which owns a Meter named Moongazing.OrionOnce (also exposed as IdempotencyDiagnostics.MeterName). It defines a single counter, orion.once.requests, tagged with orion.outcome:

acquired, replayed, in_progress, mismatch, missing_key, bypassed.

Subscribe to the meter from OpenTelemetry:

builder.Services.AddOpenTelemetry()
.WithMetrics(m => m.AddMeter(IdempotencyDiagnostics.MeterName));

The replayed response also carries the Idempotency-Replayed: true header (IdempotencyMiddleware.ReplayedHeader), which clients and proxies can inspect directly.

Testing​

The library is covered by an xUnit suite under tests/Moongazing.OrionOnce.Tests spanning the store, the fingerprint, the middleware (replay, conflict, mismatch, bypass, required-key, handler failure, 5xx not cached, body limit), and registration.

dotnet test

Benchmarks for the in-process hot paths (fingerprint, store, and combined request flow) live under benchmarks/Moongazing.OrionOnce.Benchmarks and are documented in benchmarks.md. No measured numbers are committed; run the suite on the hardware you care about:

dotnet run -c Release --project benchmarks/Moongazing.OrionOnce.Benchmarks

Versioning​

OrionOnce follows Semantic Versioning. Notable changes are recorded in CHANGELOG.md. The current release is 0.4.0; while the major version is 0, the public surface may still change between minor versions.

Design notes​

  • Multi-targets net8.0, net9.0, net10.0.
  • TreatWarningsAsErrors, latest analyzers, nullable enabled.
  • Response capture replays the status, content type, and body. Other response headers are not replayed in this version.

See docs/FEATURES.md for a deeper breakdown and docs/ROADMAP.md for ideas under consideration.

Contributing​

Contributions are welcome. Please read CONTRIBUTING.md and the CODE_OF_CONDUCT.md before opening a pull request.

More from the Orion family​

Focused .NET libraries built to one quality bar. Each is usable on its own; several share the small Orion.Abstractions contracts spine, but there is no deep dependency web — pick only what you need:

  • OrionGuard — validation, guard clauses, DDD primitives, domain events
  • Orion.Abstractions — the shared contracts spine: telemetry, options, result, clock
  • OrionAudit — automatic EF Core change-audit trail
  • OrionBeacon — leader election with fencing tokens
  • OrionClock — testable time, TTLs, and deadlines
  • OrionGrant — permission / authorization checks
  • OrionKey — source-generated strongly-typed IDs
  • OrionLedger — API-key issuance, verification, and rotation
  • OrionLens — ambient correlation-context propagation
  • OrionLock — distributed locks with fencing tokens
  • OrionPatch — transactional outbox for EF Core
  • OrionRelay — outbound webhook delivery (HMAC, retries, backoff)
  • OrionResult — Result/Option types and a shared error vocabulary
  • OrionSaga — sagas / process managers for long-running workflows
  • OrionShade — sensitive-data redaction for logs and telemetry
  • OrionStream — server-sent events / streaming hub
  • OrionVault — field-level encryption for EF Core

See it all working together in OrionShowcase, a production-shaped banking sample.

License​

MIT.

Packages​

PackageVersionDownloads
OrionOnce0.4.0696
OrionOnce.EntityFrameworkCore0.4.0226