La primera solicitud de la API pasó la prueba. Después el cliente perdió la respuesta, lo intentó de nuevo y creó el mismo pedido dos veces. Puede ocurrir después de un timeout, una actualización, un doble clic o un reintento automático. El camino feliz sigue en verde, pero la operación que necesitaba protección no tiene ninguna prueba.

Idempotencia no significa que cada llamada devuelva la misma respuesta. Significa que repetir la misma intención no debe repetir el efecto. Para probarla, cuenta el efecto observable y ejercita solicitudes repetidas, concurrentes e incompatibles. Probar webhooks con duplicados y reintentos ayuda cuando el origen es un proveedor; aquí la frontera es una API que controlas.

Diagrama que muestra una solicitud pasando por una barrera de idempotencia hacia ejecución, replay, conflicto en curso o payload diferente.

Respuesta breve

  • La primera solicitud debe ejecutar el efecto una vez.
  • La misma solicitud después de terminar debe devolver el resultado guardado sin ejecutarse de nuevo.
  • Un duplicado que llega mientras la primera solicitud sigue ejecutándose necesita un resultado explícito, como 409.
  • La misma clave con otro payload debe rechazarse, por ejemplo con 422, si el contrato compara las huellas de la solicitud.

¿Qué debe demostrar una prueba de idempotencia?

Una solicitud POST puede crear un efecto nuevo cada vez que llega. El encabezado Idempotency-Key permite que el servidor reconozca un intento repetido, pero el contrato pertenece al endpoint. La referencia de MDN sobre el encabezado Idempotency-Key separa la tarea del cliente, que reutiliza la clave al reintentar, de la tarea del servidor, que documenta la regla.

La prueba debe seguir la solicitud hasta el punto donde ocurre el efecto. Comprobar solo que la segunda solicitud encontró una clave no basta. Un bug puede detectar el duplicado y aun así dejar que el handler continúe hacia el postprocesamiento. Una issue reciente de Mastodon muestra este tipo de fallo: el duplicado se reconocía, pero el flujo posterior producía un error.

Caso Qué simular Qué verificar
Primera solicitud clave nueva y payload válido el efecto se ejecuta y devuelve éxito
Replay misma clave y payload después de terminar misma respuesta, sin segundo efecto
Concurrencia misma clave mientras la primera espera conflicto o respuesta en espera, según el contrato
Reutilización de clave misma clave con otro payload rechazo antes del efecto
Fallo recuperable el efecto falla antes de terminar regla documentada para liberar o conservar la clave

Esta matriz separa dos preguntas. Primero, ¿el endpoint reconoce la misma intención? Segundo, ¿la protección alcanza el efecto, la respuesta guardada y los caminos de error?

¿Cómo crear un handler pequeño para probarlo?

El ejemplo siguiente usa un Map para hacer visible el contrato. No es una solución de producción para varios procesos. El objetivo es crear una fixture determinista que cuente cuántas veces se llamó a la operación real.

class IdempotencyStore {
  #entries = new Map();

  begin(key, fingerprint) {
    const existing = this.#entries.get(key);
    if (!existing) {
      this.#entries.set(key, { fingerprint, state: 'running' });
      return { kind: 'new' };
    }
    if (existing.fingerprint !== fingerprint) {
      return { kind: 'payload-mismatch' };
    }
    if (existing.state === 'running') return { kind: 'in-flight' };
    return { kind: 'replay', response: existing.response };
  }

  complete(key, response) {
    const entry = this.#entries.get(key);
    if (!entry) throw new Error('missing idempotency entry');
    this.#entries.set(key, { ...entry, state: 'complete', response });
  }
}

function createHandler(store, perform) {
  return async function handle({ key, body }) {
    if (!key) return { status: 400, body: { error: 'missing_key' } };

    const fingerprint = JSON.stringify(body);
    const decision = store.begin(key, fingerprint);
    if (decision.kind === 'payload-mismatch') {
      return { status: 422, body: { error: 'key_reused_with_other_payload' } };
    }
    if (decision.kind === 'in-flight') {
      return { status: 409, body: { error: 'request_in_progress' } };
    }
    if (decision.kind === 'replay') return decision.response;

    const result = await perform(body);
    const response = { status: 201, body: result };
    store.complete(key, response);
    return response;
  };
}

Hay cuatro decisiones importantes en este código. La entrada se guarda como running antes de iniciar el efecto. Un replay devuelve la respuesta terminada. Un payload diferente nunca llega al efecto. El efecto pasa a complete solo cuando la respuesta está lista. La referencia de Stripe sobre solicitudes idempotentes describe una frontera parecida: los resultados se guardan después de que comienza la ejecución del endpoint, mientras que los conflictos concurrentes no tienen que guardar un resultado idempotente.

JSON.stringify es solo una huella sencilla para la fixture. En una API real, la huella debe ser estable y seguir el contrato del endpoint. El orden de propiedades, los campos ignorados, la normalización y los límites del payload no pueden quedar implícitos.

¿Cómo probar el replay sin duplicar el efecto?

Empieza por el caso que más se olvida: la primera solicitud terminó, pero el cliente no lo sabe. Llama al handler dos veces con la misma clave y el mismo cuerpo. La aserción más importante no es el segundo estado. Es el contador del efecto, que debe seguir en 1.

import assert from 'node:assert/strict';
import { test } from 'node:test';

test('reproduce el resultado terminado sin repetir el efecto', async () => {
  let executions = 0;
  const handle = createHandler(new IdempotencyStore(), async (body) => {
    executions += 1;
    return { id: 'order-1', ...body };
  });

  const first = await handle({ key: 'request-1', body: { item: 'book' } });
  const replay = await handle({ key: 'request-1', body: { item: 'book' } });

  assert.deepEqual(replay, first);
  assert.equal(executions, 1);
});

El runner integrado node:test espera a que termine una función de prueba asíncrona, como explica la documentación del test runner de Node.js. Guarda el handler y la prueba en un archivo .mjs, conserva las importaciones mostradas y ejecuta:

node --test idempotency.test.mjs

Este caso cubre el replay después de terminar. No demuestra que reservar la clave y crear el recurso sean operaciones atómicas en una base de datos. Esa diferencia debe aparecer en el nombre de la prueba y en la limitación documentada.

¿Cómo simular dos solicitudes concurrentes?

Para la concurrencia, el primer efecto debe quedarse pendiente. Una Promise manual permite que la segunda solicitud llegue antes de terminar, sin depender de setTimeout ni de una carrera accidental de la máquina.

test('devuelve un conflicto mientras el primer efecto sigue en curso', async () => {
  let executions = 0;
  let release;
  const pending = new Promise((resolve) => { release = resolve; });
  const handle = createHandler(new IdempotencyStore(), async (body) => {
    executions += 1;
    await pending;
    return { id: 'order-2', ...body };
  });

  const firstPromise = handle({ key: 'request-2', body: { item: 'pen' } });
  await Promise.resolve();
  const concurrent = await handle({ key: 'request-2', body: { item: 'pen' } });
  assert.equal(concurrent.status, 409);

  release();
  assert.equal((await firstPromise).status, 201);
  assert.equal(executions, 1);
});

await Promise.resolve() cede el control al handler sin añadir una espera basada en el reloj. Puedes usar otro mecanismo de sincronización, pero la intención debe seguir clara: la segunda llamada ocurre mientras el estado sigue siendo running.

El estado 409 es una decisión del contrato, no un requisito universal. MDN muestra un conflicto para una solicitud con la misma clave que todavía se está procesando, pero el cuerpo, los headers y la política de reintento siguen siendo decisiones del servicio.

¿Por qué probar la misma clave con otro payload?

Una clave identifica una intención. No es permiso para reutilizar el mismo valor en cualquier operación. Si request-3 creó un lápiz, el mismo valor no debería crear una regla. Sin esta comprobación, un reintento retrasado puede recibir o devolver el resultado de otra operación.

test('rechaza la misma clave cuando cambia el payload', async () => {
  const handle = createHandler(new IdempotencyStore(), async (body) => ({
    id: 'order-3',
    ...body,
  }));

  await handle({ key: 'request-3', body: { item: 'pencil' } });
  const mismatch = await handle({ key: 'request-3', body: { item: 'ruler' } });

  assert.equal(mismatch.status, 422);
});

Este caso sigue la idea de una huella del payload. MDN describe guardar esa huella y devolver un error cuando la misma clave llega con otra. El borrador del grupo HTTPAPI de IETF hace una distinción parecida entre replay, conflicto concurrente y una clave usada con un payload incompatible, pero todavía es un borrador y no una norma final.

¿En qué capa debe vivir esta prueba?

La fixture en memoria es una prueba rápida del protocolo del handler. Confirma el orden de las decisiones, cada camino de respuesta y el contador del efecto. Es útil, pero no reemplaza una prueba en la frontera que protege el recurso.

Usa capas complementarias:

  1. Unidad: prueba la huella del payload, la transición de running a complete y las respuestas del handler con un almacenamiento sustituto.
  2. Integración: usa la base de datos, la caché o el lock real y envía dos solicitudes suficientemente cercanas para competir por la misma clave. Verifica una sola escritura observable.
  3. Contrato del proveedor: si una API externa ofrece su propia clave, usa su entorno de prueba y confirma replay, conflicto y expiración según su documentación. No conviertas el comportamiento de Stripe en una regla para toda API.

La comparación entre pruebas unitarias y de integración en Node.js ayuda a elegir la frontera. La pregunta no es cuántas pruebas deben ser unitarias. Es qué componente debe seguir siendo real para demostrar que dos solicitudes no repiten el efecto.

Qué no puede demostrar la fixture

El Map de la demostración es deliberadamente pequeño. No coordina dos instancias del proceso, no sobrevive a un reinicio ni hace expirar las claves. Tampoco vuelve atómica la secuencia entre reservar la clave, ejecutar la operación y guardar el resultado. Una base de datos o una caché distribuida necesita una operación de reserva con la atomicidad adecuada para su entorno.

Define qué ocurre cuando falla el efecto. Algunas APIs eliminan una reserva incompleta y permiten reintentar. Otras guardan el error para que la misma intención reciba el mismo resultado. No elijas por comodidad de la prueba. Elige la semántica que el cliente pueda entender y documenta cuánto tiempo se conserva la clave.

Probar la lógica de reintentos en JavaScript sin esperar cubre el tiempo y el backoff. Este artículo tiene otro foco: un reintento puede ocurrir después de perder la respuesta, pero el efecto debe seguir siendo una sola ocurrencia.

Preguntas frecuentes

¿Llamar dos veces a la misma solicitud demuestra la idempotencia?

No. Solo demuestra un camino de replay si la segunda solicitud llega después de terminar. Añade una llamada concurrente, una clave con otro payload y un contador del efecto. Si la protección depende de una base de datos o una caché, repite la prueba en esa frontera real.

¿Un duplicado debe devolver 200?

No. El contrato puede devolver la respuesta original, un conflicto mientras la operación está en curso u otro resultado documentado. El cliente debe saber si tiene que reutilizar, esperar, corregir el payload o iniciar una nueva intención.

¿Puedo borrar la clave cuando falla el efecto?

Puedes hacerlo si el contrato trata el fallo como un intento que puede reintentarse. Otra opción es guardar la respuesta de error para que la misma intención reciba el mismo resultado. Prueba la decisión, porque borrar demasiado pronto puede permitir una segunda ejecución no deseada.

¿Un Map basta para producción?

No. Basta para probar la lógica dentro de un proceso. Producción necesita persistencia, expiración, coordinación entre workers y una relación definida entre la clave, la huella del payload, el efecto y la respuesta guardada.

Conclusión

La prueba útil no termina en el primer 201. Repite la intención después de terminar, mantiene una llamada pendiente para probar la concurrencia y cambia el payload para comprobar el contrato de la clave. Cuenta el efecto observable en cada camino.

Después repite los mismos casos con el almacenamiento real. La fixture en memoria muestra si el handler elige el camino correcto. La prueba de integración muestra si la frontera que evita la duplicación sigue funcionando con bases de datos, cachés, workers y reinicios.

Fuentes consultadas