Idempotency is makkelijk tot het tweede request anders is: een .NET-praktijkgids
Een Idempotency-Key-header en een response-cache overleven de demo. Productie sneuvelt op gelijktijdige retries, provider-timeouts en dezelfde key met een andere body. Zo bouw ik een echte idempotency-laag in ASP.NET Core en EF Core.
Jean-Pierre Broeders
Freelance .NET Developer
Idempotency is makkelijk tot het tweede request anders is
Deze maand stond er een artikel op de voorpagina van Hacker News met een titel die ik al jaren op een whiteboard wil zetten: Idempotency Is Easy Until the Second Request Is Different (HN-discussie, 160 punten, 79 reacties). De stelling van de auteur is kort en klopt. Een Idempotency-Key op een request zetten, de response opslaan en die bij een retry teruggeven is het makkelijke deel. Dat overleeft de demo. Het moeilijke begint bij het tweede request, want dat tweede request is lang niet altijd een schone herhaling van het eerste.
Ik heb deze laag gebouwd voor een payment-integratie op een .NET-stack, en ik heb ook de variant gedebugd die iemand op een vrijdagmiddag in elkaar zette en daarna vergat. Laat ik de invalshoek van het artikel daarom vastpinnen op concrete, werkende C#. De fouten zijn in elke taal hetzelfde; de oplossing heeft in ASP.NET Core en EF Core een eigen vorm.
De replay-cache die iedereen als eerste shipt
Dit is de implementatie die elke single-threaded test doorstaat en in productie omvalt:
public async Task<IResult> CreatePayment(PaymentRequest req, string idempotencyKey)
{
var existing = await _db.IdempotencyRecords
.FirstOrDefaultAsync(r => r.Key == idempotencyKey);
if (existing is not null)
return Results.Json(existing.ResponseBody, statusCode: existing.ResponseStatus);
var payment = await _payments.CreateAsync(req);
var record = new IdempotencyRecord(idempotencyKey, 201, payment);
_db.IdempotencyRecords.Add(record);
await _db.SaveChangesAsync();
return Results.Json(payment, statusCode: 201);
}
Er zitten drie bugs in die je zo over het hoofd ziet. Er is een read-then-write race: twee requests met dezelfde key kunnen allebei null zien en allebei een betaling aanmaken. Er is geen geheugen van wat het eerste commando betekende, dus een tweede request met dezelfde key maar een ander bedrag geeft stilletjes de verkeerde response terug. En er is geen begrip van "in uitvoering", dus een retry die binnenkomt terwijl het eerste call nog met de payment-provider praat, heeft geen gedefinieerd gedrag.
Het artikel somt de gevallen op die een replay-cache niet afdekt, en die zijn het onthouden waard: voltooide replay, gelijktijdige retry, gedeeltelijk lokaal succes, onbekende toestand stroomafwaarts, dezelfde key met een ander commando, duplicaat zonder key, retry na expiratie, en retry na een deploy of region-failover. Als je ontwerp alleen "zelfde commando, voltooid" aankan, heb je een cache gebouwd, geen idempotency-laag.
Stap één: een atomaire insert bepaalt wie de uitvoering bezit
De belangrijkste fix is om te stoppen met lezen vóór je schrijft. Laat de database de eigenaarschap regelen met een unique constraint, en insert eerst. In EF Core betekent dat een scoped samengestelde sleutel modelleren — nooit een globaal unieke idempotency-key, want een kapotte client die abc-123 genereert mag alleen met zichzelf botsen, niet met een andere tenant.
public class IdempotencyRecord
{
public required string TenantId { get; init; }
public required string Operation { get; init; } // "create_payment"
public required string Key { get; init; }
public required string RequestHash { get; init; }
public IdempotencyStatus Status { get; set; }
public int? ResponseStatus { get; set; }
public string? ResponseBody { get; set; }
public string? ResourceId { get; set; }
public DateTimeOffset CreatedAt { get; init; }
public DateTimeOffset ExpiresAt { get; init; }
public DateTimeOffset? LockedUntil { get; set; }
}
// OnModelCreating
modelBuilder.Entity<IdempotencyRecord>()
.HasKey(r => new { r.TenantId, r.Operation, r.Key });
public enum IdempotencyStatus
{
InProgress,
Completed,
FailedReplayable,
FailedRetryable,
UnknownRequiresRecovery
}
De acquisitiestap is een insert die het conflict opslokt. Op PostgreSQL met Npgsql is dat ON CONFLICT DO NOTHING; het aantal geraakte rijen vertelt je of je de race hebt gewonnen:
const string sql = """
INSERT INTO idempotency_records
(tenant_id, operation, key, request_hash, status,
created_at, expires_at, locked_until)
VALUES
({0}, 'create_payment', {1}, {2}, 'InProgress',
{3}, {4}, {5})
ON CONFLICT (tenant_id, operation, key) DO NOTHING;
""";
var inserted = await _db.Database.ExecuteSqlRawAsync(sql,
tenantId, key, requestHash,
now, now.AddHours(24), now.AddSeconds(30));
if (inserted == 1)
{
// Wij bezitten de uitvoering. Doorgaan.
}
else
{
// Iemand anders bezit hem. Inspecteer het bestaande record en beslis.
}
Op SQL Server is het equivalent: een unique index laten gooien en DbUpdateException met SQL-fout 2627/2601 opvangen — lelijker, maar hetzelfde idee: één schrijver wint, atomair. Emuleer dit niet met een SELECT gevolgd door een INSERT. En emuleer het zeker niet met een lock-statement in C#; jouw proces is niet het enige dat verkeer afhandelt.
Een Redis SET key value NX EX 30 wordt vaak als dé oplossing voorgesteld. Dat is het niet. In het beste geval is het een uitvoeringsslot dat gelijktijdige duplicaten beperkt. Verloopt het slot terwijl de provider-call nog loopt, dan komt een ander request alsnog binnen. Sterft het proces nadat de provider slaagde maar voordat je de uitkomst opsloeg, dan zegt het slot de retry niets. Redis kan helpen, maar het is geen duurzaam geheugen van wat er is gebeurd.
Stap twee: hash het commando, niet de bytes
Het geval "zelfde key, andere body" scheidt een echte implementatie van speelgoed. Het standpunt van het artikel — dat ik deel voor alles wat met geld te maken heeft — is dat een scoped key die hergebruikt wordt met een ander canoniek commando een harde fout moet zijn, of de eerste operatie nu voltooid, gefaald of nog bezig is. Stilletjes de eerste response teruggeven terwijl de client iets anders vroeg, is geen idempotency maar herinterpretatie. Een client die denkt veilig een betaling van €10 te herhalen, mag nooit later ontdekken dat de server stiekem één van €100 heeft bewaard.
Maar je kunt geen rauwe bytes vergelijken. {"amount":"10.00","currency":"EUR"} en {"currency":"EUR","amount":"10.00"} zijn hetzelfde commando; veldvolgorde en witruimte mogen niet uitmaken. De regel luidt: hash het gevalideerde commando, niet de HTTP-body. Parse naar een DTO, normaliseer de waarden die je API als gelijkwaardig behandelt, gooi transport-metadata weg, en hash dan canoniek.
public static string CanonicalHash(CreatePaymentCommand cmd)
{
// Projecteer naar een stabiele, geordende vorm. Normaliseer bedrag en enums.
var canonical = new SortedDictionary<string, string?>
{
["operation"] = "create_payment",
["accountId"] = cmd.AccountId,
["amount"] = decimal.Parse(cmd.Amount).ToString("0.00", CultureInfo.InvariantCulture),
["currency"] = cmd.Currency.ToUpperInvariant(),
["merchantReference"] = cmd.MerchantReference,
["apiVersion"] = cmd.ApiVersion
};
var json = JsonSerializer.Serialize(canonical);
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(json));
return Convert.ToHexString(bytes);
}
Let op wat er buiten valt: de Authorization-header, de idempotency-key zelf, en alles wat alleen de vorm van de response bepaalt. En let op wat een beslissing vraagt: server-side default-velden (als channel standaard "web" is, zijn een request mét en zonder dat veld dan hetzelfde commando?) en onbekende velden die je nu negeert maar na een deploy betekenisvol kunnen worden. De hash is een contract. Verander je hoe hij berekend wordt, dan gaan de legitieme retries van gisteren er ineens als conflicten uitzien.
Verliest de insert de race, dan laad je de bestaande rij en vertak je op de status:
var rec = await _db.IdempotencyRecords.FindAsync(tenantId, "create_payment", key);
if (rec!.RequestHash != requestHash)
return Results.Json(
new { errorCode = "IDEMPOTENCY_KEY_REUSED_WITH_DIFFERENT_REQUEST" },
statusCode: 409);
return rec.Status switch
{
IdempotencyStatus.Completed
=> Results.Json(rec.ResponseBody, statusCode: rec.ResponseStatus!.Value),
IdempotencyStatus.InProgress when rec.LockedUntil > now
=> Results.Json(new { status = "processing" }, statusCode: 409), // + Retry-After
IdempotencyStatus.InProgress // verlopen slot
=> await RecoverOwnership(rec),
IdempotencyStatus.UnknownRequiresRecovery
=> await TriggerReconciliation(rec),
_ => Results.StatusCode(500)
};
409 Conflict is een verdedigbare default voor de mismatch, omdat het request botst met de betekenis die de server voor die scoped key onthoudt. Sommige teams kiezen 422. Wat telt is een stabiele, machineleesbare foutcode en geen stille replay voor een ander commando.
Stap drie: de provider-timeout is waar je garantie ophoudt
Dit is de faalmodus die de goedkope implementaties nooit overwegen, en degene die echt geld kost. De volgorde is alledaags: je inserteert InProgress, maakt lokaal betaling pay_789 aan, belt de provider stroomafwaarts, de provider accepteert de afschrijving — en dan loopt je proces in een timeout, crasht het, of raakt de response kwijt. De client retried met dezelfde key. Je database kan niet afleiden of er geld is bewogen.
De fix bestaat uit twee delen. Ten eerste: houd geen databasetransactie open tijdens de provider-call; commit je lokale InProgress-status en een stabiele stroomafwaartse identiteit vóór je belt. Ten tweede: geef de stroomafwaartse call zijn eigen idempotency-key, afgeleid van jouw stabiele resource-id, niet van de key van de client:
// Lokaal, in ÉÉN transactie:
await using var tx = await _db.Database.BeginTransactionAsync();
var payment = new Payment { Id = "pay_789", Status = "PENDING" };
_db.Payments.Add(payment);
_db.OutboxEvents.Add(OutboxEvent.PaymentCreated(payment.Id));
rec.ResourceId = payment.Id;
await _db.SaveChangesAsync();
await tx.CommitAsync();
// Buiten de transactie, met een afgeleide, stabiele provider-key:
var providerKey = $"provider_payment_{payment.Id}";
var result = await _provider.ChargeAsync(payment, providerKey);
Doordat de provider-key provider_payment_pay_789 is en niet abc-123, kan een recovery-worker later de provider op die key bevragen om uit te vinden of de afschrijving doorging, in plaats van blind opnieuw af te schrijven. De retry-logica wordt: is het record Completed, replay; is het een verse InProgress, geef 202 of 409 met Retry-After; is het een verlopen InProgress, claim dan atomair recovery-eigenaarschap, bevraag de provider, en zet het record op Completed of UnknownRequiresRecovery. Heeft de provider noch een idempotency-key noch een query-API, dan heb je een operationeel gat — je mag dat accepteren, maar wees eerlijk dat je lokale tabel het externe effect dan niet beschermt.
Je queue-consumer heeft exact dezelfde bug
HTTP krijgt de aandacht omdat de header zichtbaar is, maar de meeste dubbele neveneffecten die ik heb opgejaagd zaten in consumers: outbox-publishers, notificatie-workers, ledger-schrijvers. De broker die "exactly-once delivery" belooft, geeft je geen exactly-once business-effect. Dat komt van duurzame operatie-id's en unique constraints, net als op het schrijfpad.
// Consumer-kant: dedupe op een business-key, schrijf achter een unique constraint.
modelBuilder.Entity<LedgerEntry>()
.HasIndex(e => new { e.EntryType, e.SourcePaymentId })
.IsUnique();
public async Task Handle(PaymentCreated evt)
{
var entry = new LedgerEntry
{
EntryType = "payment_received",
SourcePaymentId = evt.PaymentId,
Amount = evt.Amount
};
_db.LedgerEntries.Add(entry);
try { await _db.SaveChangesAsync(); }
catch (DbUpdateException ex) when (IsUniqueViolation(ex))
{
// Al verwerkt. Dit is succes, geen fout.
}
}
En pas op voor de volgordeval: markeer je een bericht als verwerkt voordat je de bevestigingsmail stuurt en crash je daarna, dan slaat de retry de mail voor altijd over. Stuur je de mail eerst en crash je daarna, dan stuurt de retry hem twee keer. De betrouwbare vorm is het neveneffect duurzaam maken vóór je het triggert — een mailrij invoegen met een unique key, en een aparte sender die rij laten verwerken.
Wanneer je dit allemaal niet moet bouwen
De kosten zitten niet in de header; ze zitten in het duurzame geheugen en het herstelgedrag erachter. Bouw geen payment-grade laag voor een admin-actie waarvan een duplicaat onschadelijk en zichtbaar is. Voor veel operaties verslaat een business-key een willekeurige idempotency-key volledig:
modelBuilder.Entity<Payment>()
.HasIndex(p => new { p.AccountId, p.MerchantReference })
.IsUnique();
Als de regel echt "één betaling per merchant-referentie per account" is, dan vangt die constraint duplicaten op, zelfs als een buggy client retried met een nieuwe willekeurige key. En soms is de beste fix de operatie hervormen tot een van nature idempotente PUT /accounts/{id}/settings/default-currency, waarbij het herhalen van het request de instelling gewoon laat staan waar hij stond.
De kernboodschap
De makkelijke versie van idempotency onthoudt dat een key gezien is. De bruikbare versie onthoudt wat de key betekende: de scoped operatie, het canonieke commando, de uitvoeringsstatus, de resulterende resource, het expiratievenster, en genoeg faaltoestand om onzekerheid niet in een dubbele afschrijving te laten ontaarden. Het tweede request kan een retry zijn, een andere operatie die dezelfde key draagt, een race met het eerste, of een aankomst nadat de provider slaagde maar je proces niet. De taak van de server is bewijzen welk geval het is — replayen, de mismatch afwijzen, of herstellen, in plaats van gokken. In .NET is dat bewijs een unique constraint, een insert-first eigenaarschapsstap, een canonieke commando-hash, en een stroomafwaartse key die je zelf beheert. Al het andere is de cache die de demo overleeft, en verder niets.
Bron: "Idempotency Is Easy Until the Second Request Is Different" van Dochia — Hacker News-discussie. De codevoorbeelden zijn van mijzelf.
