Idempotency keys zijn makkelijk — totdat het tweede request anders is
Een trending Hacker News-post legt precies de vinger op de zere plek bij idempotente API's. Zo implementeer ik idempotency keys correct in .NET, inclusief het conflictgeval dat iedereen vergeet.
Jean-Pierre Broeders
Freelance .NET Developer
Idempotency keys zijn makkelijk — totdat het tweede request anders is
Deze week klom een blogpost met de titel "Idempotency Is Easy Until the Second Request Is Different" naar de voorpagina van Hacker News, en hij raakte een gevoelige snaar bij een hoop backend-engineers — ik incluis. De stelling klinkt bedrieglijk simpel: het standaardpatroon voor idempotentie (een Idempotency-Key meesturen, het antwoord opslaan, en bij een retry hetzelfde antwoord teruggeven) handelt het saaie geval prima af. Het lastige begint pas wanneer het tweede request met dezelfde key géén schone herhaling is van het eerste. Zelfde key, ander bedrag. En nu?
Ik heb voor e-commerceklanten betaal- en order-API's opgeleverd waar precies deze dubbelzinnigheid het verschil was tussen "we hebben de betaling één keer verwerkt" en "we hebben een klant dubbel afgeschreven en de middag besteed aan excuusmails". Daarom wil ik het argument uit het artikel serieus nemen en laten zien hoe een correcte implementatie er in .NET uitziet — niet de demo op het gelukkige pad, maar de versie die een kapotte client overleeft.
Wat idempotentie je daadwerkelijk oplevert
Idempotentie betekent dat een operatie meerdere keren kan worden toegepast zonder dat het resultaat verandert ten opzichte van de eerste keer. GET, PUT en DELETE zijn volgens hun HTTP-semantiek idempotent. POST is dat niet, en daar zit precies de pijn: een order aanmaken, een kaart belasten, geld overmaken. Dit zijn nu juist de operaties die een client veilig wil kunnen herhalen, want netwerken falen op de slechtst denkbare manier — het request slaagt op de server, maar het antwoord komt nooit terug. De client heeft geen idee of de betaling heeft plaatsgevonden. Hij probeert het opnieuw. Zonder idempotentie schrijf je twee keer af.
De oplossing is om de client per logische operatie een unieke sleutel te laten meesturen:
POST /api/payments HTTP/1.1
Idempotency-Key: 2f1c9b7e-3a44-4c2e-9d6e-7b0a8e5f1c23
Content-Type: application/json
{ "orderId": "ord_8842", "amountCents": 1000, "currency": "EUR" }
Heeft de server die key al eens gezien, dan geeft hij het opgeslagen antwoord terug in plaats van de operatie nog eens uit te voeren. Stripe heeft dit ontwerp populair gemaakt en het is inmiddels de de-facto standaard. De rechttoe-rechtaan-versie in ASP.NET Core ziet er zo uit:
public sealed record IdempotencyRecord(
string Key,
string RequestHash,
int StatusCode,
string ResponseBody,
DateTimeOffset CreatedAt);
public interface IIdempotencyStore
{
Task<IdempotencyRecord?> GetAsync(string tenantId, string key, CancellationToken ct);
Task<bool> TryReserveAsync(string tenantId, string key, string requestHash, CancellationToken ct);
Task CompleteAsync(string tenantId, string key, int statusCode, string body, CancellationToken ct);
}
Tot zover makkelijk. Waar het artikel op wijst — en wat de meeste tutorials overslaan — is alles wat hieronder volgt.
Bij het tweede request wordt het interessant
Dit is het scenario waar de HN-discussie steeds op terugkwam. Een client stuurt een betaling met key abc-123 voor €10. Vervolgens komt er een tweede request binnen met dezelfde key abc-123, maar de body zegt nu €10.000. Wat moet de server doen?
Er zijn drie aannemelijke interpretaties, en ze sluiten elkaar uit:
- Het is een retry. Geef het opgeslagen €10-antwoord terug en negeer de nieuwe body.
- Het is een clientbug. De client hergebruikt een key die hij niet had mogen hergebruiken. Wijs het luid af.
- Het is een nieuwe operatie. Behandel
(key + inhoud)als een nieuwe identiteit en verwerk €10.000.
Het artikel betoogt — terecht, naar mijn ervaring — dat de standaardkeuze voor API's met neveneffecten zou moeten zijn: dezelfde gescopete key + een ander canoniek commando is een harde fout. Optie 2. Je geeft 409 Conflict terug (of 422), je doet niets, en je maakt de fout van de client onmiddellijk zichtbaar.
Waarom is dat het juiste uitgangspunt? Omdat optie 1 stilletjes een request weggooit waarvan de client denkt dat hij het verstuurd heeft, en optie 3 stilletjes een operatie uitvoert die de client misschien nooit voor dat bedrag bedoeld heeft. Allebei zijn catastrofaal als het om geld gaat. Een client die denkt veilig een betaling van €10 te herhalen, mag nooit meemaken dat de server het tweede request ongemerkt als iets anders interpreteert. De hele belofte van idempotentie is "je kunt opnieuw proberen zonder erbij na te denken". Die belofte stil verbreken is erger dan hem niet doen.
De request canoniseren
Om "zelfde key, ander request" te detecteren, heb je een deterministische vingerafdruk van het request nodig. De ruwe bytes hashen is broos: een herschikte JSON-property of een verschil in witruimte zou de hash laten kantelen en een legitieme retry in een vals conflict veranderen. Canoniseer dus eerst.
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
public static class RequestCanonicalizer
{
// Bereken een stabiele hash over de semantisch betekenisvolle inhoud.
public static string ComputeHash(string rawJson)
{
var node = JsonNode.Parse(rawJson) ?? throw new JsonException("Lege body");
var canonical = Canonicalize(node);
var bytes = Encoding.UTF8.GetBytes(canonical.ToJsonString());
return Convert.ToHexString(SHA256.HashData(bytes));
}
private static JsonNode Canonicalize(JsonNode node) => node switch
{
JsonObject obj => new JsonObject(
obj.OrderBy(p => p.Key, StringComparer.Ordinal)
.Select(p => KeyValuePair.Create(
p.Key,
p.Value is null ? null : Canonicalize(p.Value)))),
JsonArray arr => new JsonArray(
arr.Select(e => e is null ? null : Canonicalize(e)).ToArray()),
_ => JsonNode.Parse(node.ToJsonString())!
};
}
Nu leveren { "amountCents": 1000, "currency": "EUR" } en { "currency": "EUR", "amountCents": 1000 } dezelfde hash op, maar het bedrag aanpassen naar 1000000 levert een andere op. Dat is precies het onderscheid waar de conflictcontrole op draait.
Een kanttekening die het artikel impliceert maar die het waard is om expliciet te maken: wees bewust over wat er in het canonieke commando terechtkomt. Een trace-id of een client-side timestamp-header hoort niet in de vingerafdruk thuis, anders ziet elke retry er anders uit. Hash de zakelijke intentie — het bedrag, de valuta, de doelrekening — en niets anders.
Alles samenbrengen in middleware
Ik handel idempotentie liever af in middleware dan dat ik het over al mijn controllers uitsmeer. Dat houdt het beleid op één plek en uit de bedrijfslogica. De flow kent drie toestanden: key nooit gezien (reserveren en uitvoeren), key gezien en afgerond (afspelen), key gezien maar request wijkt af (conflict).
public sealed class IdempotencyMiddleware(RequestDelegate next, IIdempotencyStore store)
{
public async Task InvokeAsync(HttpContext context)
{
// Alleen onveilige methodes bewaken die zich via de header aanmelden.
if (!HttpMethods.IsPost(context.Request.Method) ||
!context.Request.Headers.TryGetValue("Idempotency-Key", out var keyValues))
{
await next(context);
return;
}
var key = keyValues.ToString();
var tenantId = context.User.FindFirst("tenant_id")?.Value ?? "anonymous";
var ct = context.RequestAborted;
context.Request.EnableBuffering();
using var reader = new StreamReader(context.Request.Body, leaveOpen: true);
var rawBody = await reader.ReadToEndAsync(ct);
context.Request.Body.Position = 0;
var requestHash = RequestCanonicalizer.ComputeHash(rawBody);
var existing = await store.GetAsync(tenantId, key, ct);
if (existing is not null)
{
if (existing.RequestHash != requestHash)
{
context.Response.StatusCode = StatusCodes.Status409Conflict;
await context.Response.WriteAsJsonAsync(new
{
error = "idempotency_key_reuse",
message = "Deze Idempotency-Key is al gebruikt met een andere request body."
}, ct);
return;
}
// Echte retry: speel het opgeslagen antwoord letterlijk af.
context.Response.StatusCode = existing.StatusCode;
context.Response.ContentType = "application/json";
await context.Response.WriteAsync(existing.ResponseBody, ct);
return;
}
if (!await store.TryReserveAsync(tenantId, key, requestHash, ct))
{
// Er is op dit moment al een request met dezelfde key onderweg.
context.Response.StatusCode = StatusCodes.Status409Conflict;
await context.Response.WriteAsJsonAsync(new
{
error = "request_in_progress",
message = "Een request met deze Idempotency-Key wordt al verwerkt."
}, ct);
return;
}
// Vang het antwoord van de downstream af zodat we het kunnen bewaren.
var originalBody = context.Response.Body;
using var buffer = new MemoryStream();
context.Response.Body = buffer;
await next(context);
buffer.Position = 0;
var responseBody = await new StreamReader(buffer).ReadToEndAsync(ct);
buffer.Position = 0;
await buffer.CopyToAsync(originalBody, ct);
context.Response.Body = originalBody;
if (context.Response.StatusCode is >= 200 and < 300)
await store.CompleteAsync(tenantId, key, context.Response.StatusCode, responseBody, ct);
}
}
Twee details die het verschil maken tussen speelgoed en een productie-implementatie. Ten eerste bewaar ik alleen geslaagde antwoorden. Faalt de operatie met een 500, dan moet de client het opnieuw kunnen proberen; een fout voor eeuwig cachen zou een valkuil zijn. Ten tweede vangt de reservering met TryReserveAsync gelijktijdige duplicaten op — twee retries die met elkaar racen voordat de eerste klaar is. Zonder die reservering passeren beide de controle "heb ik deze key al gezien?" en zit je weer met een dubbele afschrijving.
Scope de key — hij is niet globaal uniek
De belangrijkste ontwerpkeuze uit het artikel is makkelijk te missen: de idempotency key is niet globaal uniek tenzij je hem bewust globaal maakt, en meestal hoort hij dat niet te zijn. Een kapotte client die voor alles abc-123 genereert, mag alleen ooit botsen met zichzelf, niet met een andere tenant die toevallig dezelfde string koos. Daarom neemt elke methode op mijn store een tenantId. De uniciteitsconstraint zit op de combinatie, niet op de key alleen.
In EF Core is dat één regel, en het is precies de regel die voorkomt dat tenant A het antwoord van tenant B afspeelt:
modelBuilder.Entity<IdempotencyRecord>()
.HasKey(r => new { r.TenantId, r.Key });
De atomaire reservering mapt netjes op de database. Met PostgreSQL geeft een INSERT ... ON CONFLICT DO NOTHING je een lock-vrije reserveer-of-faal in één round trip:
public async Task<bool> TryReserveAsync(
string tenantId, string key, string requestHash, CancellationToken ct)
{
const string sql = """
INSERT INTO idempotency_records (tenant_id, key, request_hash, status_code, response_body, created_at)
VALUES ({0}, {1}, {2}, 0, '', now())
ON CONFLICT (tenant_id, key) DO NOTHING;
""";
var rows = await _db.Database.ExecuteSqlRawAsync(sql, ct,
tenantId, key, requestHash);
return rows == 1; // 1 = wij wonnen de race, 0 = iemand had al gereserveerd
}
Voor endpoints met veel verkeer grijp ik liever naar Redis, met SET key value NX EX 86400 voor de reservering en een TTL zodat verlopen in-flight records vanzelf verdwijnen. Het principe is identiek — atomair reserveren, gescopete key, begrensde levensduur — alleen de opslagmotor verandert.
Waar ik een kanttekening plaats
De "harde fout bij mismatch"-regel van het artikel is de juiste standaard, maar niet universeel. Voor sommige API's is de key echt een deduplicatievenster en geen contract over de inhoud, en is een afwijkende body als nieuwe operatie behandelen het gedocumenteerde gedrag. De discipline zit hem erin om expliciet te kiezen en het te documenteren, in plaats van wat je framework toevallig doet je semantiek te laten worden. De faalmodus die ik heb opgeruimd, is nooit "het team koos bewust voor optie 3". Het is "niemand koos iets, en het gedrag ontstond uit een GetOrCreate-aanroep die iemand er haastig in had geschreven".
Dan is er nog de vraag hoelang je keys bewaart. Stripe houdt ze 24 uur aan; dat is een redelijke standaard. Bewaar je ze voor altijd, dan groeit je store ongebreideld; laat je ze te snel verlopen, dan krijgt een client die na een lange storing opnieuw probeert een verse uitvoering in plaats van een afspeling. Koppel de TTL aan hoelang je clients realistisch gezien retryen, en maak hem langer dan je langste backoff-schema.
Conclusie
Idempotency keys zijn zo'n feature die na twintig minuten af lijkt en zijn echte diepte pas in productie prijsgeeft, doorgaans op het slechtst denkbare moment. Het trending HN-stuk heeft gelijk dat het makkelijke geval een afleiding is. Het werk zit in het tweede request: canoniseer de body zodat echte retries worden herkend, wijs een mismatchend hergebruik af met een 409 zodat clientbugs vroeg opvallen, scope de key per tenant zodat hij niet over grenzen heen kan botsen, en reserveer atomair zodat gelijktijdige retries er niet doorheen glippen. Doe die vier dingen en je hebt iets gebouwd dat zijn belofte daadwerkelijk waarmaakt — dat een client opnieuw kan proberen zonder erbij na te denken, en je klanten precies één keer worden afgeschreven.
Bouw je aan een betaal- of order-API en wil je een tweede paar ogen op de idempotentie- en webhookafhandeling? Neem contact op — het is grotendeels wat ik doe.
Bron: "Idempotency Is Easy Until the Second Request Is Different" en de Hacker News-discussie.
