Construimos una API real en .NET 10 donde un
POSTpuede 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 producen409o422.
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:
- una aplicación envía
POST /api/payments; - el servidor crea el pago;
- la conexión se corta antes de que llegue la respuesta;
- el cliente reintenta;
- 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:
b566e7fad483ff4528503dfd1ac8913f159bd6707f01fe302c5b13e8222f5fa7Después de descomprimirlo:
chmod +x verify.sh
./verify.shEl 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 Requestsi faltaIdempotency-Key;201 Createden la primera ejecución;- el mismo
201, cuerpo e identificador en un reintento completado; 409 Conflictmientras la petición original sigue procesándose;422 Unprocessable Contentsi 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.mdEl 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

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
processingocompleted; - 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:
- No existe: inserta
processing, generaowner_tokeny fija el lease. - Hash diferente: devuelve
PayloadMismatch. - Está completado: devuelve status, content type y body guardados.
- Sigue bloqueado: devuelve
InProgress. - 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=Truey 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: trueEl 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 ContentDuplicados 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 ConflictAl 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_keyEsto 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-transitiveLa auditoría final no reporta paquetes vulnerables.
Cuándo no usaría este patrón
No necesitas Idempotency-Key para:
- operaciones
GET,HEAD,PUToDELETEque 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
processingycompleted. - Devuelve
409para una operación aún pendiente. - Devuelve
422si 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:
- identificar la intención del cliente;
- comprobar que el payload no cambió;
- reclamar la operación de forma atómica;
- distinguir ejecución, replay y trabajo pendiente;
- confirmar efecto y respuesta juntos;
- conservar el resultado durante una ventana documentada;
- 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
- The Idempotency-Key HTTP Header Field — IETF Datatracker
- draft-ietf-httpapi-idempotency-key-header-07
- RFC 8941 — Structured Field Values for HTTP
- .NET and .NET Core support policy
- Microsoft.Data.Sqlite 10.0.10 — NuGet
- Transactions — Microsoft.Data.Sqlite
- Database errors, locking and retries — Microsoft.Data.Sqlite
- Handle errors in ASP.NET Core APIs
- RFC 9110 — HTTP Semantics