En pocas palabras
Técnica que permite reintentar peticiones y reprocesar mensajes sin efectos duplicados, mediante claves de idempotencia, deduplicación y operaciones naturalmente idempotentes.
Qué problema resuelve
Reintentos por timeouts, doble clic o entregas duplicadas del broker que generan cobros, transferencias o registros repetidos.
Cuándo utilizarlo
Cualquier operación con efectos económicos o externos. Ejemplo: un endpoint de pagos que el cliente móvil reintenta al perder cobertura.
De la idea a la práctica
Funcionamiento, ejemplos y decisiones para pasar al siguiente nivel.
Idempotencia en HTTP
RFC 9110 define como idempotentes GET, HEAD, OPTIONS, PUT y DELETE; POST y PATCH no lo son por defecto. Que un método lo sea por especificación no lo hace por implementación: un PUT que incrementa un contador rompe la promesa.
Clave de idempotencia
El cliente genera un identificador único por intención de operación (no por intento) y lo envía en cada reintento:
POST /v1/pagos HTTP/1.1
Idempotency-Key: 6f1b2c9e-4c1a-4b7e-9d1e-0a6c2f6a1b10
Content-Type: application/json
{"creditoId":"42","monto":250000,"moneda":"COP"}
El servidor:
- Busca la clave. Si no existe, la registra como
EN_PROCESOjunto con un hash del cuerpo. - Ejecuta la operación y guarda el código y la respuesta.
- Ante una repetición con la misma clave y el mismo cuerpo, devuelve la respuesta guardada.
- Con la misma clave y un cuerpo distinto, responde
422; si la primera sigue en curso,409.
CREATE TABLE idempotency_keys (
key text PRIMARY KEY,
request_hash text NOT NULL,
status text NOT NULL, -- EN_PROCESO | COMPLETADA
response_code int,
response_body jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
-- La restricción de unicidad resuelve la carrera entre dos reintentos simultáneos:
INSERT INTO idempotency_keys (key, request_hash, status)
VALUES ($1, $2, 'EN_PROCESO')
ON CONFLICT (key) DO NOTHING;
Registrar la clave y aplicar el efecto en la misma transacción evita estados a medias. Define una retención (Stripe, por ejemplo, conserva las claves al menos 24 horas).
Consumidores de mensajes
Kafka, Pub/Sub o SQS entregan al menos una vez. Opciones para el consumidor:
| Estrategia | Cómo |
|---|---|
| Deduplicación por id | Tabla processed_messages(message_id) en la transacción del efecto |
| Upsert natural | INSERT … ON CONFLICT (pago_id) DO NOTHING |
| Versión / secuencia | Ignorar eventos con versión menor o igual a la ya aplicada |
| Operación absoluta | SET saldo = 1000 en lugar de saldo = saldo - 50 |
Antipatrones frecuentes
- Generar la clave en el servidor: el cliente no puede reutilizarla al reintentar.
- Guardar la clave en caché sin persistencia: un reinicio vuelve a permitir duplicados.
- Comprobar «¿existe?» y luego insertar en dos pasos sin unicidad: condición de carrera.
- Reintentar con nueva clave tras un timeout: equivale a no tener idempotencia.
Idempotencia con Spring Boot
@PostMapping("/v1/pagos")
ResponseEntity<PagoDto> crear(@RequestHeader("Idempotency-Key") @NotBlank String clave,
@Valid @RequestBody PagoRequest req) {
String hash = Hashes.sha256(req);
return idempotencia.ejecutar(clave, hash, () -> {
Pago pago = pagos.registrar(req); // misma transacción que el registro de la clave
return ResponseEntity.status(201).body(PagoDto.de(pago));
});
}
@Transactional
public <T> ResponseEntity<T> ejecutar(String clave, String hash, Supplier<ResponseEntity<T>> accion) {
int insertadas = jdbc.update("""
INSERT INTO idempotency_keys (key, request_hash, status) VALUES (?, ?, 'EN_PROCESO')
ON CONFLICT (key) DO NOTHING""", clave, hash);
if (insertadas == 0) {
var previa = repo.buscar(clave);
if (!previa.hash().equals(hash)) throw new ConflictoIdempotencia(422, "Cuerpo distinto para la misma clave");
if (previa.enProceso()) throw new ConflictoIdempotencia(409, "Petición original en curso");
return previa.respuestaGuardada();
}
var respuesta = accion.get();
repo.completar(clave, respuesta);
return respuesta;
}
Si la acción llama a un tercero (pasarela de pago), la transacción local no basta: propaga la misma clave al proveedor si la soporta, o guarda el estado intermedio y reconcilia.
Clientes que reintentan correctamente
async function pagar(datos: Pago) {
const clave = crypto.randomUUID(); // una por intención de pago, no por intento
for (let intento = 0; intento < 4; intento++) {
try {
const res = await fetch('/v1/pagos', {
method: 'POST',
headers: {
'Idempotency-Key': clave,
'Content-Type': 'application/json',
},
body: JSON.stringify(datos),
});
if (res.status !== 409 && res.status < 500) return res;
} catch {
/* error de red: reintentar con la misma clave */
}
await esperar(Math.random() * 2 ** intento * 300); // backoff con jitter
}
throw new Error('No se pudo confirmar el pago');
}
En apps móviles, persiste la clave junto al pago pendiente: si la app se cierra y se reabre, debe reintentar con la misma clave.
Probar la idempotencia
| Prueba | Resultado esperado |
|---|---|
| Misma petición dos veces seguidas | Un solo pago; la segunda devuelve la misma respuesta |
| Dos peticiones simultáneas con la misma clave | Un pago; la otra recibe 409 o la respuesta guardada |
| Misma clave con importe distinto | 422 |
| Fallo tras registrar la clave y antes de completar | El reintento no duplica (transacción revertida o estado reconciliado) |
| Mensaje duplicado en el consumidor | Efecto aplicado una sola vez |
Criterios de implementación
Exige Idempotency-Key en todos los POST con efectos económicos y documéntalo en el contrato de la API. Propaga la clave hacia proveedores externos que la soporten. Combínalo con reintentos con backoff (patrones de resiliencia) y prueba la concurrencia con dos peticiones simultáneas con la misma clave.
Continúa en las fuentes
Documentación oficial para verificar detalles y explorar más.