← Volver al blogCómo hacer una API idempotente en ASP.NET Core: reintentos sin duplicar operaciones
dotnetaspnet-coreapiidempotencysqlite

Cómo hacer una API idempotente en ASP.NET Core: reintentos sin duplicar operaciones

Construye una API .NET 10 segura para reintentos con Idempotency-Key, fingerprints, SQLite, replay exacto, concurrencia y respuestas 409/422.

Construimos una API real en .NET 10 donde un POST puede repetirse sin crear dos pagos: la primera petición ejecuta la operación, los reintentos completados reciben exactamente la respuesta guardada y los usos incorrectos de la clave producen 409 o 422.

Un timeout no significa que el servidor no ejecutó la operación. Significa que el cliente no sabe si la ejecutó. Esa diferencia es suficiente para duplicar un pago, una orden o un correo.

Imagina este flujo:

  1. una aplicación envía POST /api/payments;
  2. el servidor crea el pago;
  3. la conexión se corta antes de que llegue la respuesta;
  4. el cliente reintenta;
  5. el servidor crea un segundo pago.

El reintento era razonable. El problema fue que la API no tenía forma de reconocer que ambas peticiones representaban la misma intención.

Una clave de idempotencia agrega esa identidad:

Idempotency-Key: "payment-order-1001"

Con ella, la API puede distinguir cuatro casos:

  • una operación nueva que debe ejecutarse;
  • un reintento completado que debe reproducir la respuesta;
  • un duplicado que llegó mientras la primera petición sigue en curso;
  • una clave reutilizada por error con otro payload.

Qué vamos a construir

El proyecto usa ASP.NET Core 10, Microsoft.Data.Sqlite 10.0.10 y SQLite como almacenamiento durable.

Descargar el proyecto funcional

SHA-256:

b566e7fad483ff4528503dfd1ac8913f159bd6707f01fe302c5b13e8222f5fa7

Después de descomprimirlo:

chmod +x verify.sh
./verify.sh

El script no simula el comportamiento con llamadas directas a clases. Levanta la API, ejecuta peticiones HTTP reales, lanza duplicados concurrentes y reinicia el proceso.

El resultado esperado es:

Build succeeded.
0 Warning(s)
0 Error(s)

The given project `IdempotencyDemo` has no vulnerable packages.

Missing key: 400
First execution: 201
Completed retry: 201, exact response replayed
Different payload: 422
Concurrent duplicates: 5 x 409 while the owner completed
Persisted replay after restart: 201
Payment rows created: 2
All idempotency checks passed.

El demo demuestra:

  • 400 Bad Request si falta Idempotency-Key;
  • 201 Created en la primera ejecución;
  • el mismo 201, cuerpo e identificador en un reintento completado;
  • 409 Conflict mientras la petición original sigue procesándose;
  • 422 Unprocessable Content si la misma clave llega con otro payload;
  • una sola fila de pago por operación lógica;
  • persistencia de la respuesta después de reiniciar la API;
  • auditoría NuGet sin paquetes vulnerables.

Estructura final del proyecto

aspnetcore-idempotency-demo/
├── Contracts/
│   ├── CreatePaymentRequest.cs
│   ├── PaymentResponse.cs
│   └── PaymentValidation.cs
├── Idempotency/
│   ├── IdempotencyException.cs
│   ├── IdempotencyExceptionHandler.cs
│   ├── IdempotencyExecutor.cs
│   ├── IdempotencyKey.cs
│   ├── IdempotencyModels.cs
│   ├── IdempotencyOptions.cs
│   └── IdempotencyStore.cs
├── Infrastructure/
│   └── SqliteDatabase.cs
├── Payments/
│   └── PaymentRepository.cs
├── IdempotencyDemo.csproj
├── Program.cs
├── appsettings.json
├── verify.sh
└── README.md

El flujo de idempotencia

sequenceDiagram
    participant C as Cliente
    participant A as API
    participant S as Registro idempotente
    participant P as Pago

    C->>A: POST /payments + clave K + payload P
    A->>S: Claim(K, hash(P))

    alt K es nueva
        S-->>A: acquired
        A->>P: crear pago
        A->>S: commit pago + respuesta 201
        A-->>C: 201 · Replayed=false
    else K + hash(P) ya completados
        S-->>A: respuesta almacenada
        A-->>C: 201 · Replayed=true
    else K + hash(P) siguen en curso
        S-->>A: outstanding
        A-->>C: 409 Conflict
    else K existe con otro hash
        S-->>A: fingerprint mismatch
        A-->>C: 422 Unprocessable Content
    end

Primera ejecución, replay y conflicto de una Idempotency-Key

La animación mantiene la misma clave durante los tres casos. El contador de efectos secundarios pasa de 0 a 1 en la primera ejecución y nunca vuelve a incrementarse.

Antes del código: el contrato HTTP

El documento más reciente del IETF es draft-ietf- httpapi-idempotency-key-header-07. En agosto de 2026 es un Internet-Draft expirado —venció el 18 de abril de 2026—; no se ha publicado una revisión -08 ni un RFC.

El borrador define Idempotency-Key como un Item Structured Field (RFC 8941) cuyo valor es de tipo String. Por eso la forma normativa usa comillas:

Idempotency-Key: "payment-order-1001"

El demo también acepta la forma sin comillas porque varios proveedores y clientes existentes la usan, pero los ejemplos del artículo mantienen la sintaxis del borrador.

La semántica relevante es:

Situación Respuesta
Falta una clave requerida 400 Bad Request
Primera combinación clave + fingerprint Procesar normalmente
Reintento después de completar Reproducir el resultado anterior
Mismo key mientras la operación sigue pendiente 409 Conflict
Mismo key con otro payload 422 Unprocessable Content

La expiración queda en manos del servidor. Este ejemplo conserva los registros durante 24 horas y comunica la fecha mediante el header personalizado Idempotency-Key-Expires-At.

Idempotency-Replayed tampoco es un header estándar: lo añadimos únicamente para hacer observable si la respuesta fue ejecutada o reproducida.

1. Validar antes de reservar una clave

El endpoint recibe un pago pequeño:

public sealed record CreatePaymentRequest(
    long AmountCents,
    string Currency,
    string Reference)
{
    public CreatePaymentRequest Normalize() =>
        this with
        {
            Currency = Currency.Trim().ToUpperInvariant(),
            Reference = Reference.Trim()
        };
}

La validación ocurre antes de reclamar la clave. Una petición inválida no debe ocupar almacenamiento idempotente:

Dictionary<string, string[]> errors =
    PaymentValidation.Validate(request);

if (errors.Count > 0)
{
    return TypedResults.ValidationProblem(errors);
}

Después normalizamos el payload. usd y USD representan la misma moneda, así que deben producir el mismo fingerprint.

2. Leer y validar Idempotency-Key

El parser exige una sola cabecera y limita la clave a 128 caracteres ASCII:

public static string Parse(IHeaderDictionary headers)
{
    StringValues values = headers["Idempotency-Key"];

    if (values.Count == 0)
    {
        throw IdempotencyException.KeyRequired();
    }

    if (values.Count != 1)
    {
        throw IdempotencyException.KeyInvalid(
            "Exactly one Idempotency-Key header is required.");
    }

    string value = values[0]?.Trim() ?? string.Empty;

    if (value.Length >= 2 &&
        value.StartsWith('"') &&
        value.EndsWith('"'))
    {
        value = value[1..^1];
    }

    if (!KeyPattern().IsMatch(value))
    {
        throw IdempotencyException.KeyInvalid(
            "Use 1-128 ASCII letters, numbers, dots, underscores, colons, or hyphens.");
    }

    return value;
}

En una API pública también conviene exigir suficiente entropía —por ejemplo un UUID— para evitar colisiones accidentales. La clave no debería contener credenciales, correos ni otros datos personales: terminará almacenada y probablemente aparecerá en logs.

3. Crear un fingerprint del request

Una clave sola no es suficiente. Si el cliente reutiliza "payment-order-1001" con otro importe, reproducir la respuesta anterior sería engañoso.

El demo combina el nombre de la operación con el JSON normalizado y calcula SHA-256:

private static string CreateFingerprint(
    string operationName,
    string requestJson)
{
    byte[] bytes = Encoding.UTF8.GetBytes(
        $"{operationName}\n{requestJson}");
    byte[] digest = SHA256.HashData(bytes);

    return Convert.ToHexString(digest);
}

Incluir la operación impide que una clave usada en POST /payments sea interpretada como la misma intención en otro endpoint.

La canonicalización importa

Serializar un record normalizado es suficiente para este contrato pequeño y controlado. No generalices eso a cualquier JSON recibido como texto.

Estos documentos pueden ser semánticamente iguales:

{"amount":100,"currency":"USD"}
{"currency":"USD","amount":100}

Sus bytes no son iguales. Para contratos más flexibles deberías:

  • deserializar a un modelo estable;
  • normalizar mayúsculas, espacios, zonas horarias y valores equivalentes;
  • usar JSON canónico determinista;
  • o calcular el fingerprint solo sobre campos de negocio seleccionados.

El fingerprint es parte del contrato, no un detalle incidental de serialización.

4. Guardar estado, lease y respuesta

SQLite mantiene dos tablas:

CREATE TABLE idempotency_records (
    key TEXT PRIMARY KEY,
    request_hash TEXT NOT NULL,
    status TEXT NOT NULL
        CHECK (status IN ('processing', 'completed')),
    owner_token TEXT NOT NULL,
    status_code INTEGER NULL,
    content_type TEXT NULL,
    response_body TEXT NULL,
    created_at TEXT NOT NULL,
    locked_until TEXT NOT NULL,
    expires_at TEXT NOT NULL
);

CREATE TABLE payments (
    id TEXT PRIMARY KEY,
    idempotency_key TEXT NOT NULL,
    amount_cents INTEGER NOT NULL,
    currency TEXT NOT NULL,
    reference TEXT NOT NULL,
    created_at TEXT NOT NULL
);

El registro idempotente conserva:

  • hash del request;
  • estado processing o completed;
  • token del worker que posee el lease;
  • status code, content type y cuerpo de la respuesta;
  • timestamps de creación, bloqueo y expiración.

El cuerpo se guarda porque un replay debe devolver el mismo paymentId, no crear un objeto parecido con otro identificador o timestamp.

5. Reclamar la clave de forma atómica

ClaimAsync abre una conexión y una transacción corta:

await using SqliteConnection connection = database.CreateConnection();
await connection.OpenAsync(cancellationToken);
using SqliteTransaction transaction = connection.BeginTransaction();

DateTimeOffset now = DateTimeOffset.UtcNow;
await DeleteExpiredAsync(connection, transaction, now, cancellationToken);

StoredRecord? existing = await ReadAsync(
    connection,
    transaction,
    key,
    cancellationToken);

Dentro de la misma transacción decide:

  1. No existe: inserta processing, genera owner_token y fija el lease.
  2. Hash diferente: devuelve PayloadMismatch.
  3. Está completado: devuelve status, content type y body guardados.
  4. Sigue bloqueado: devuelve InProgress.
  5. El lease venció: asigna un nuevo owner y permite recuperar el trabajo.

SQLite permite un solo writer pendiente. Microsoft.Data.Sqlite reintenta errores busy y locked hasta alcanzar el timeout. Cada operación abre su propia SqliteConnection; esos objetos no son thread-safe y no deben compartirse entre requests.

El connection string configura:

Default Timeout=10;Pooling=True

y la base usa WAL:

PRAGMA journal_mode = WAL;
PRAGMA synchronous = NORMAL;

6. Ejecutar el efecto y guardar la respuesta juntos

Después de obtener el claim, IdempotencyExecutor decide si debe ejecutar, reproducir o rechazar:

IdempotencyClaim claim =
    await store.ClaimAsync(key, fingerprint, cancellationToken);

switch (claim.Kind)
{
    case IdempotencyClaimKind.PayloadMismatch:
        throw IdempotencyException.PayloadMismatch();

    case IdempotencyClaimKind.InProgress:
        throw IdempotencyException.RequestInProgress();

    case IdempotencyClaimKind.Replay:
        return new IdempotencyExecutionResult(
            claim.StatusCode!.Value,
            claim.ContentType!,
            claim.ResponseBody!,
            Replayed: true,
            claim.ExpiresAt);
}

La primera ejecución abre otra transacción. La creación del pago y la transición del registro a completed se confirman juntas:

await using SqliteConnection connection = database.CreateConnection();
await connection.OpenAsync(cancellationToken);
using SqliteTransaction transaction = connection.BeginTransaction();

PaymentResponse response = await operation(
    connection,
    transaction,
    cancellationToken);

string responseBody = JsonSerializer.Serialize(response, JsonOptions);

int completed = await store.CompleteAsync(
    connection,
    transaction,
    key,
    ownerToken,
    StatusCodes.Status201Created,
    "application/json; charset=utf-8",
    responseBody,
    cancellationToken);

if (completed != 1)
{
    throw IdempotencyException.OwnershipLost();
}

transaction.Commit();

Si insertar el pago funciona pero almacenar la respuesta falla, la transacción completa se revierte. No queda un pago sin respuesta idempotente asociada.

El UPDATE verifica owner_token. Un worker cuyo lease fue reclamado no puede marcar el registro como completado.

7. Devolver la respuesta exacta

El endpoint añade información de observabilidad:

context.Response.Headers["Idempotency-Key"] = key;
context.Response.Headers["Idempotency-Replayed"] =
    result.Replayed ? "true" : "false";
context.Response.Headers["Idempotency-Key-Expires-At"] =
    result.ExpiresAt.ToString("O");

return Results.Content(
    result.Body,
    result.ContentType,
    statusCode: result.StatusCode);

Un reintento completado no vuelve a ejecutar PaymentRepository. Devuelve los bytes persistidos con el mismo status code.

El demo guarda respuestas exitosas. En producción debes decidir y documentar qué errores también se almacenan. Una validación previa normalmente no ocupa la clave; un error determinista del negocio puede ser candidato; un fallo transitorio de infraestructura quizá debería liberar el claim.

8. Usar Problem Details para los errores

Las excepciones idempotentes se convierten en application/problem+json mediante IExceptionHandler:

ProblemDetails problem = new()
{
    Status = idempotencyException.StatusCode,
    Title = idempotencyException.Title,
    Detail = idempotencyException.Message,
    Type = $"https://example.com/problems/{idempotencyException.Code}"
};

problem.Extensions["code"] = idempotencyException.Code;

Ejemplo de una clave reutilizada:

{
  "type": "https://example.com/problems/idempotency.key_reused",
  "title": "Idempotency-Key is already used",
  "status": 422,
  "detail": "The same Idempotency-Key cannot be reused with a different request payload.",
  "code": "idempotency.key_reused"
}

Para el 409 también devolvemos Retry-After: 1.

9. Probarlo sin frontend

Primera ejecución:

curl -i -X POST http://127.0.0.1:5089/api/payments \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: "payment-order-1001"' \
  -d '{"amountCents":2599,"currency":"USD","reference":"order-1001"}'

Respuesta:

HTTP/1.1 201 Created
Idempotency-Replayed: false
Content-Type: application/json; charset=utf-8
{
  "id": "adfc499b-f1b1-4ffd-b7f7-946ad21c1e64",
  "amountCents": 2599,
  "currency": "USD",
  "reference": "order-1001",
  "createdAt": "2026-08-08T22:11:28.9393421+00:00"
}

Repite el mismo comando. La API responde:

HTTP/1.1 201 Created
Idempotency-Replayed: true

El cuerpo es idéntico, incluido id y createdAt.

Payload diferente

curl -i -X POST http://127.0.0.1:5089/api/payments \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: "payment-order-1001"' \
  -d '{"amountCents":9999,"currency":"USD","reference":"different"}'

Resultado:

HTTP/1.1 422 Unprocessable Content

Duplicados concurrentes

verify.sh inicia una petición propietaria y, mientras espera, envía cinco duplicados con la misma clave y payload.

El resultado medido es:

owner: 201 Created
duplicate 1: 409 Conflict
duplicate 2: 409 Conflict
duplicate 3: 409 Conflict
duplicate 4: 409 Conflict
duplicate 5: 409 Conflict

Al terminar el owner, un nuevo retry obtiene 201 con Idempotency-Replayed: true.

Persistencia tras reinicio

El script detiene la API, la vuelve a iniciar con el mismo archivo SQLite y repite la primera petición.

Recibe el mismo paymentId y el contador final confirma:

{
  "count": 2
}

Hay dos filas porque el script usa dos claves únicas: una para el flujo normal y otra para la prueba concurrente. Los múltiples reintentos no añadieron filas.

Consideraciones de producción

Idempotencia no significa “exactly once”

Este patrón garantiza una sola escritura local cuando el efecto y el registro idempotente comparten transacción.

No puede deshacer un cargo que ya fue enviado a un proveedor externo antes de que el proceso falle. Para pagos reales:

  • envía también una clave idempotente al proveedor;
  • conserva la correlación entre ambas claves;
  • usa outbox, inbox o una máquina de estados para workflows largos;
  • reconcilia operaciones cuyo resultado externo quedó desconocido.

Los leases necesitan fencing

El owner_token evita que un worker viejo confirme la respuesta después de perder el lease.

Pero no impide que ese worker haya ejecutado previamente un efecto externo. El lease debe superar la duración normal del trabajo y el sistema downstream debe aceptar un fencing token o su propia clave idempotente.

Varias instancias necesitan storage compartido

Un archivo SQLite local funciona para una instancia. Con varios contenedores, cada réplica tendría su propio registro y podría ejecutar el mismo request.

Usa PostgreSQL, Redis con operaciones atómicas u otro almacén durable compartido. La operación de claim debe seguir siendo atómica.

La clave debe incluir el scope correcto

El demo tiene un solo endpoint y usa key como primary key. Una API real normalmente usa una clave compuesta:

tenant_id + operation + idempotency_key

Esto evita colisiones entre clientes, usuarios y endpoints.

Define retención y límites

Guardar respuestas indefinidamente hace crecer la tabla. Documenta:

  • duración de la clave;
  • tamaño máximo del body almacenado;
  • política de limpieza;
  • comportamiento después de expirar;
  • si los errores se almacenan;
  • qué headers se reproducen.

El demo elimina registros vencidos de manera oportunista durante un nuevo claim. Un servicio con largos periodos de inactividad necesita un job de limpieza dedicado.

Protege respuestas sensibles

El registro puede contener datos personales. Aplica cifrado, control de acceso, retención mínima y redacción de logs. Nunca permitas que un usuario consulte la respuesta idempotente de otro tenant.

No guardes el body sin límites

Persistir respuestas pequeñas funciona bien. Para archivos o respuestas grandes guarda un identificador o referencia a object storage.

Paquetes nativos también cuentan

Durante la construcción, el paquete por defecto resolvió inicialmente un bundle nativo de SQLite con una vulnerabilidad alta conocida. El proyecto final fija SQLitePCLRaw.bundle_e_sqlite3 3.0.5, que incluye SQLite 3.53.4, y verify.sh ejecuta:

dotnet list package --vulnerable --include-transitive

La auditoría final no reporta paquetes vulnerables.

Cuándo no usaría este patrón

No necesitas Idempotency-Key para:

  • operaciones GET, HEAD, PUT o DELETE que ya tienen semántica idempotente correctamente implementada;
  • consultas sin efectos secundarios;
  • comandos internos procesados por una cola que ya tiene inbox y deduplicación;
  • acciones donde cada repetición debe producir deliberadamente otro resultado.

Sí lo usaría para:

  • pagos, reembolsos y transferencias;
  • creación de órdenes;
  • alta de suscripciones;
  • generación única de recursos costosos;
  • webhooks que el emisor puede reenviar;
  • comandos móviles ejecutados sobre redes inestables.

Checklist para producción

  • Exige una clave con suficiente entropía.
  • Sigue la sintaxis documentada y define compatibilidad.
  • Normaliza el payload antes de calcular el fingerprint.
  • Incluye tenant y operación en el scope.
  • Reclama la clave de forma atómica.
  • Distingue processing y completed.
  • Devuelve 409 para una operación aún pendiente.
  • Devuelve 422 si cambia el fingerprint.
  • Guarda status, content type, body y headers relevantes.
  • Confirma el efecto local y la respuesta en una sola transacción.
  • Usa lease, owner token y fencing.
  • Propaga idempotencia a proveedores externos.
  • Define expiración, limpieza y límites de tamaño.
  • Usa almacenamiento compartido con varias instancias.
  • Protege los cuerpos almacenados como datos sensibles.
  • Prueba concurrencia, crashes, reinicios y timeouts reales.

Conclusión

Hacer un POST seguro para reintentos no consiste en guardar una clave en memoria.

Necesitas:

  1. identificar la intención del cliente;
  2. comprobar que el payload no cambió;
  3. reclamar la operación de forma atómica;
  4. distinguir ejecución, replay y trabajo pendiente;
  5. confirmar efecto y respuesta juntos;
  6. conservar el resultado durante una ventana documentada;
  7. extender la garantía hasta cualquier sistema externo involucrado.

La idempotencia no elimina los fallos de red. Convierte la incertidumbre que producen en un contrato que el cliente y el servidor pueden resolver sin duplicar operaciones.

Fuentes

Comentarios

Cargando comentarios…