Cargando...

Idempotency Key en ASP.NET Core (.NET 8): evitar POST duplicados

Haz que POST sea seguro ante reintentos: usa Idempotency-Key, guarda la primera respuesta y reprodúcela en duplicados para evitar inserciones dobles.

Las solicitudes POST no son idempotentes por defecto. Si un cliente (especialmente una app móvil) sufre un timeout de red, puede reenviar el mismo POST automáticamente. Si el servidor procesa esa solicitud dos veces, puedes terminar con inserciones duplicadas, cobros duplicados o reservas duplicadas.

Una solución práctica: Idempotency-Key. El cliente envía una clave única en un header por cada POST. El servidor procesa la solicitud una sola vez, guarda la respuesta y, si llega la misma clave otra vez, reproduce exactamente la respuesta original (replay). Así los “retries” se vuelven seguros.


Objetivo


¿Cómo funciona?


Paso 1: un contrato pequeño para el store

En producción, si tienes múltiples instancias, la idempotencia normalmente debe apoyarse en un store compartido (Redis/DB). En este ejemplo usamos un store en memoria para que la idea sea fácil de seguir.


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);
}

Paso 2: store en memoria (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;
    }
}

Paso 3: middleware (captura + replay)

El middleware revisa el header Idempotency-Key en solicitudes POST. Si encuentra un registro en caché, escribe la misma respuesta. Si no, captura el body de salida en un buffer; cuando el endpoint termina, copia el buffer al cliente y lo guarda para el siguiente retry.


using Microsoft.AspNetCore.Http;

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

    // TTL pequeña para la demo
    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("Idempotency-Key inválido.");
            return;
        }

        // Incluye el path en la cache key para evitar colisiones entre endpoints
        var cacheKey = $"{context.Request.Path}:{key}";

        // 1) Replay si existe
        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) Si no existe, capturar la respuesta
        var originalBody = context.Response.Body;

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

        try
        {
            await _next(context);

            // Demo: si quieres, puedes guardar solo respuestas exitosas.
            // En sistemas reales, a veces también tiene sentido guardar 4xx (según la semántica).
            var status = context.Response.StatusCode;
            var contentType = context.Response.ContentType ?? "application/json";

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

            // Copiar la respuesta al stream real (al cliente)
            buffer.Position = 0;
            await buffer.CopyToAsync(originalBody, context.RequestAborted);

            // Guardar un set mínimo de headers (evitar 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;
        }
    }
}

Paso 4: conectar todo (Program.cs)


var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

// Demo: store en memoria (single instance)
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<IIdempotencyStore, MemoryIdempotencyStore>();

var app = builder.Build();

// Debe ejecutarse antes de los controllers
app.UseMiddleware<IdempotencyMiddleware>();

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

Endpoint de ejemplo

El endpoint queda limpio: la lógica de idempotencia no vive dentro del 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)
    {
        // Imagina que aquí insertas en la DB y devuelves un nuevo id de recurso.
        var id = Random.Shared.Next(1000, 9999);

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

Prueba rápida

Envía el mismo POST dos veces con la misma key. La segunda respuesta debería ser 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\"}"

En la segunda respuesta deberías ver Idempotency-Replay: true y el mismo body/status que en la primera llamada.


Notas importantes


TL;DR

  • Idempotency-Key hace que POST sea seguro ante reintentos.
  • Guarda la primera respuesta y reprodúcela en duplicados.
  • Si tienes múltiples instancias, usa un store compartido (Redis/DB).