Una misma llamada de red puede llegar dos veces: la conexión se corta durante la respuesta, el cliente reintenta por su cuenta, un proveedor reenvía su webhook. Sin una salvaguarda, el servidor crea dos pedidos, envía dos correos o cobra dos veces. La clave de idempotencia es el mecanismo estándar que reconoce la repetición y devuelve el resultado de la primera llamada, sin cambios.
Por qué una misma llamada llega dos veces
La semántica HTTP (RFC 9110) califica GET, PUT y DELETE como idempotentes: repetirlos deja el servidor en el mismo estado. POST no lo es, y es justamente el verbo que crea recursos, dispara pagos o envía mensajes. Un cliente bien construido reintenta tras un tiempo de espera agotado sin saber si la primera petición se procesó antes del corte.
La misma incertidumbre gobierna las colas de mensajes y los webhooks, que garantizan una entrega al menos una vez. Por eso un consumidor recibe a veces el mismo evento dos veces, y un doble clic en un botón de pedido produce la misma colisión en el navegador. Lo que comparten estos casos: el emisor no sabe si el efecto ya ocurrió.
Qué garantiza, y qué no, una clave de idempotencia
El principio cabe en una frase: el cliente adjunta a cada operación de negocio un identificador único, transmitido en la cabecera Idempotency-Key. El servidor asocia esa clave al resultado que produjo. Si la clave reaparece, devuelve la respuesta ya calculada en lugar de repetir el trabajo.
POST /v1/payments HTTP/1.1
Host: api.exemple.com
Idempotency-Key: 5f0c8b1e-2a3d-4c6f-9b21-7d0e4a9c1f88
Content-Type: application/json
{"amount": 4200, "currency": "eur", "order": "cmd_9182"}Conviene fijar con claridad el alcance del mecanismo. Una clave de idempotencia deduplica dos peticiones que llevan la misma clave; no deshace nada ni concilia dos claves distintas dirigidas a la misma intención. Convierte un «al menos una vez» de red en un «exactamente una vez» en el efecto, siempre que la clave designe la intención de negocio y no el intento HTTP.
Una clave de idempotencia no hace reversible la operación: solo impide que ocurra dos veces.
El ciclo de una petición idempotente
Al llegar, el servidor busca la clave. Ausente, la registra como «en curso», ejecuta el trabajo y luego guarda el código y el cuerpo de la respuesta. Presente y terminada, devuelve la respuesta guardada. Presente pero aún en curso, avisa de que una petición idéntica ya está en vuelo.
Guardar las claves: el núcleo del mecanismo
Todo descansa en una tabla dedicada y en una restricción de unicidad. Es esa restricción, y no una comprobación previa en la aplicación, la que arbitra dos peticiones concurrentes: la inserción que gana ejecuta el trabajo, la otra falla y pasa a leer el resultado guardado.
CREATE TABLE idempotency_keys (
id BIGSERIAL PRIMARY KEY,
scope TEXT NOT NULL, -- compte + endpoint
idem_key TEXT NOT NULL,
request_hash TEXT NOT NULL, -- empreinte du corps
status TEXT NOT NULL, -- 'in_progress' | 'completed'
response_code SMALLINT,
response_body JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
UNIQUE (scope, idem_key) -- verrou atomique
);El manejador se articula en torno a esa inserción atómica. También compara una huella del cuerpo: la misma clave con una carga distinta señala una reutilización abusiva, mejor rechazada que servida con un resultado engañoso.
public function store(Request $request): JsonResponse
{
$key = $request->header('Idempotency-Key');
$scope = $request->user()->id . ':POST /v1/payments';
$hash = hash('sha256', $request->getContent());
// Insertion atomique : la contrainte d'unicité arbitre les concurrents
try {
DB::table('idempotency_keys')->insert([
'scope' => $scope, 'idem_key' => $key, 'request_hash' => $hash,
'status' => 'in_progress', 'expires_at' => now()->addDay(),
]);
} catch (QueryException $e) { // Cle deja presente : rejeu ou requete concurrente
$row = DB::table('idempotency_keys')
->where(compact('scope'))->where('idem_key', $key)->first();
if ($row->request_hash !== $hash) {
return response()->json(['error' => 'key_reuse'], 422); // Meme cle, corps different : reutilisation interdite
}
if ($row->status === 'in_progress') {
return response()->json(['error' => 'in_flight'], 409); // Operation encore en cours cote serveur
}
return response()->json($row->response_body, $row->response_code); // Rejeu : on renvoie la reponse d'origine, a l'identique
}
$payment = $this->charge($request); // Le traitement metier n'a lieu qu'une fois
DB::table('idempotency_keys')->where(compact('scope'))->where('idem_key', $key)
->update(['status' => 'completed', 'response_code' => 201,
'response_body' => $payment]);
return response()->json($payment, 201);
}La respuesta original se conserva tal cual, código de estado incluido, para volver a servirla en el reintento.
{
"scope": "acct_42:POST /v1/payments",
"idem_key": "5f0c8b1e-2a3d-4c6f-9b21-7d0e4a9c1f88",
"request_hash": "b9f3...c1",
"status": "completed",
"response_code": 201,
"response_body": { "id": "pay_7Kd2", "amount": 4200, "status": "succeeded" },
"expires_at": "2026-08-30T22:00:00Z"
}Varias estrategias de deduplicación coexisten según el contexto.
| Enfoque | Garantía | Coste | Cuándo elegirlo |
|---|---|---|---|
| Restricción de unicidad en base de datos | Atómica, resiste la concurrencia | Un índice, una tabla | Caso general, escrituras transaccionales |
Bloqueo de aplicación (Redis SET NX) | Rápido, fuera de la base | Dependencia externa, expiración por gestionar | Tráfico alto, respuesta guardada en otro sitio |
| Deduplicación por identificador de evento | Sencilla del lado del consumidor | Solo cubre flujos con identificador estable | Webhooks, colas de mensajes |
Las trampas en producción
Concurrencia. Dos peticiones con la misma clave llegan a la vez. Sin inserción atómica, ambas pasan la comprobación «¿existe la clave?» y ejecutan el trabajo. La restricción de unicidad resuelve el conflicto en el momento de escribir: el perdedor recibe un 409 o espera la respuesta en curso, nunca un segundo cobro.
Reaparecen otras tres trampas. El alcance de la clave debe incluir la cuenta y el endpoint: una clave compartida entre dos clientes abre una fuga de respuesta. La expiración evita una tabla que crece sin fin; una ventana de veinticuatro horas a unos pocos días cubre los reintentos realistas. Por último, la idempotencia no sustituye a la coherencia transaccional: la escritura de negocio y la actualización del estado de la clave deberían compartir la misma transacción, so pena de una respuesta marcada como «terminada» cuando el cobro en realidad falló.
Del lado del cliente y del proveedor: generar y consumir las claves
El cliente genera una clave aleatoria, un UUID v4 por ejemplo, una vez por intención de negocio. El hábito decisivo: conservar esa clave para todos los reintentos de la misma operación y, sobre todo, no regenerarla en cada intento HTTP, o la garantía desaparece. Las grandes API de pago exponen la cabecera con este nombre exacto y devuelven un error explícito en caso de colisión.
Del lado del consumo de eventos, la lógica es simétrica. Un webhook lleva un identificador estable; el consumidor conserva los identificadores ya procesados e ignora los reenvíos. Es la misma idempotencia, aplicada a la entrada en lugar de a la salida.
Lo que conviene recordar
- Una clave de idempotencia convierte una entrega «al menos una vez» en un efecto «exactamente una vez», siempre que designe la intención de negocio.
- La deduplicación fiable descansa en una restricción de unicidad en la base de datos, no en una comprobación previa de la aplicación.
- La clave se acota por cuenta y por endpoint, expira tras una ventana corta, y la respuesta original se vuelve a servir sin cambios.
- Del lado del cliente, una clave por operación, reutilizada en cada reintento; del lado del consumidor, deduplicación por identificador de evento.
He visto más incidentes de doble cobro causados por una clave mal acotada que por la ausencia de clave: una clave reutilizada para dos operaciones distintas, o regenerada en cada intento, y la garantía se derrumba en silencio. La regla que conservo del terreno se resume en dos puntos: una clave por intención de negocio, nunca por petición HTTP, y una prueba que repita deliberadamente la llamada antes de cualquier puesta en producción. — Simon Janvier
Para profundizar
Especificación de referencia: The Idempotency-Key HTTP Header Field, grupo de trabajo HTTP API del IETF.
