Ir al contenido

El medio de los artesanos de la web domingo, 30 de agosto de 2026

MailStudio
Back-end

Claves de idempotencia: reintentar API y webhooks sin duplicados

Una misma llamada de red puede llegar dos veces: un corte, un reintento automático, un webhook reenviado. La clave de idempotencia permite al servidor reconocer la repetición y devolver el primer resultado, sin duplicados ni doble cobro.

Idempotence : requetes rejouables

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.

Cliente Idempotency-Key Servidor clave nueva clave conocida Ejecuta y guarda Devuelve lo guardado

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.

EnfoqueGarantíaCosteCuándo elegirlo
Restricción de unicidad en base de datosAtómica, resiste la concurrenciaUn índice, una tablaCaso general, escrituras transaccionales
Bloqueo de aplicación (Redis SET NX)Rápido, fuera de la baseDependencia externa, expiración por gestionarTráfico alto, respuesta guardada en otro sitio
Deduplicación por identificador de eventoSencilla del lado del consumidorSolo cubre flujos con identificador estableWebhooks, 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.

También en Mail Studio

Compartir LinkedIn Bluesky Hacker News E-mail

Leer también