Chargement...

Idempotency Key en ASP.NET Core (.NET 8) : éviter les doublons en POST

Rendez les POST sûrs en cas de retry : lisez Idempotency-Key, stockez la première réponse puis rejouez-la pour les doublons afin d’éviter les doubles insertions.

Les requêtes POST ne sont pas idempotentes par défaut. Lorsqu’un client (souvent une app mobile) subit un timeout réseau, il peut renvoyer automatiquement le même POST. Si le serveur traite cette requête deux fois, on peut vite obtenir des insertions en double, une facturation en double, ou des réservations en double.

Une solution très pratique : la clé d’idempotence (Idempotency-Key). Le client envoie une clé unique dans un header. Le serveur exécute l’opération une seule fois, stocke la réponse, puis si la même clé revient, il rejoue la réponse d’origine (replay). Les retries deviennent ainsi sûrs.


Objectif


Comment ça marche ?


Étape 1 : un petit contrat de stockage

En production, si vous avez plusieurs instances, l’idempotence doit en général s’appuyer sur un store partagé (Redis/DB). Ici, on utilise un store en mémoire pour rendre l’idée facile à suivre.


public sealed record IdempotencyRecord(
    int StatusCode,
    string ContentType,
    byte[] Body,
    Dictionary<string, string> Headers,
    DateTimeOffset CreatedAt);

public interface IIdempotencyStore
{
    Task<IdempotencyRecord?> GetAsync(string key, CancellationToken ct);
    Task SetAsync(string key, IdempotencyRecord record, TimeSpan ttl, CancellationToken ct);
}

Étape 2 : store en mémoire (IMemoryCache)


using Microsoft.Extensions.Caching.Memory;

public sealed class MemoryIdempotencyStore(IMemoryCache cache) : IIdempotencyStore
{
    private readonly IMemoryCache _cache = cache;

    public Task<IdempotencyRecord?> GetAsync(string key, CancellationToken ct)
    {
        _cache.TryGetValue(key, out IdempotencyRecord? record);
        return Task.FromResult(record);
    }

    public Task SetAsync(string key, IdempotencyRecord record, TimeSpan ttl, CancellationToken ct)
    {
        _cache.Set(key, record, ttl);
        return Task.CompletedTask;
    }
}

Étape 3 : middleware (capture + replay)

Le middleware vérifie le header Idempotency-Key sur les requêtes POST. Si un enregistrement existe, il réécrit la même réponse. Sinon, il met le body de réponse en buffer, renvoie ce buffer au client, puis le stocke pour les retries suivants.


using Microsoft.AspNetCore.Http;

public sealed class IdempotencyMiddleware(
    RequestDelegate next,
    IIdempotencyStore store)
{
    private readonly RequestDelegate _next = next;
    private readonly IIdempotencyStore _store = store;

    // TTL courte pour la démo
    private static readonly TimeSpan DefaultTtl = TimeSpan.FromMinutes(10);

    public async Task InvokeAsync(HttpContext context)
    {
        if (!HttpMethods.IsPost(context.Request.Method))
        {
            await _next(context);
            return;
        }

        if (!context.Request.Headers.TryGetValue("Idempotency-Key", out var keyValues))
        {
            await _next(context);
            return;
        }

        var key = keyValues.ToString().Trim();

        if (string.IsNullOrWhiteSpace(key) || key.Length > 128)
        {
            context.Response.StatusCode = StatusCodes.Status400BadRequest;
            await context.Response.WriteAsync("Clé d'idempotence invalide.");
            return;
        }

        // Inclure le path dans la clé de cache pour éviter les collisions entre endpoints
        var cacheKey = $"{context.Request.Path}:{key}";

        // 1) Replay si présent
        var existing = await _store.GetAsync(cacheKey, context.RequestAborted);
        if (existing is not null)
        {
            context.Response.StatusCode = existing.StatusCode;
            context.Response.ContentType = existing.ContentType;

            foreach (var h in existing.Headers)
                context.Response.Headers[h.Key] = h.Value;

            context.Response.Headers["Idempotency-Replay"] = "true";

            await context.Response.Body.WriteAsync(existing.Body, context.RequestAborted);
            return;
        }

        // 2) Sinon, capturer la réponse
        var originalBody = context.Response.Body;

        await using var buffer = new MemoryStream();
        context.Response.Body = buffer;

        try
        {
            await _next(context);

            // Démo : à vous de décider quoi stocker (souvent 2xx, parfois 4xx).
            var status = context.Response.StatusCode;
            var contentType = context.Response.ContentType ?? "application/json";

            buffer.Position = 0;
            var bodyBytes = buffer.ToArray();

            // Copier la réponse vers le vrai stream (vers le client)
            buffer.Position = 0;
            await buffer.CopyToAsync(originalBody, context.RequestAborted);

            // Stocker un set minimal de headers (éviter Set-Cookie, etc.)
            var headers = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

            if (context.Response.Headers.TryGetValue("Location", out var loc))
                headers["Location"] = loc.ToString();

            var record = new IdempotencyRecord(
                StatusCode: status,
                ContentType: contentType,
                Body: bodyBytes,
                Headers: headers,
                CreatedAt: DateTimeOffset.UtcNow);

            await _store.SetAsync(cacheKey, record, DefaultTtl, context.RequestAborted);
        }
        finally
        {
            context.Response.Body = originalBody;
        }
    }
}

Étape 4 : brancher le tout (Program.cs)


var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

// Démo : store en mémoire (single instance)
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<IIdempotencyStore, MemoryIdempotencyStore>();

var app = builder.Build();

// Doit s’exécuter avant les controllers
app.UseMiddleware<IdempotencyMiddleware>();

app.MapControllers();
app.Run();

Endpoint d’exemple

L’endpoint reste propre : aucune logique d’idempotence dans le controller.


using Microsoft.AspNetCore.Mvc;

public sealed record CreateReservationRequest(string Code, string Country);

[ApiController]
[Route("api/reservations")]
public sealed class ReservationsController : ControllerBase
{
    [HttpPost]
    public IActionResult Create([FromBody] CreateReservationRequest req)
    {
        // Imaginez ici un insert en DB qui retourne un nouvel id.
        var id = Random.Shared.Next(1000, 9999);

        return Created($"/api/reservations/{id}", new
        {
            id,
            req.Code,
            req.Country
        });
    }
}

Test rapide

Envoyez deux fois le même POST avec la même clé. La seconde réponse doit être un replay.


curl -i -X POST "https://localhost:5001/api/reservations" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7d2f4f2b-7bcb-4f8f-a5e2-0d9b6f4e8f1a" \
  -d "{\"code\":\"RSV-001\",\"country\":\"TR\"}"

curl -i -X POST "https://localhost:5001/api/reservations" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: 7d2f4f2b-7bcb-4f8f-a5e2-0d9b6f4e8f1a" \
  -d "{\"code\":\"RSV-001\",\"country\":\"TR\"}"

Sur la seconde réponse, vous devriez voir Idempotency-Replay: true et le même body/status que le premier appel.


Notes importantes


TL;DR

  • Une Idempotency-Key rend les POST sûrs en cas de retry.
  • Stockez la première réponse et rejouez-la en cas de doublon.
  • Avec plusieurs instances, utilisez un store partagé (Redis/DB).