🌐Desarrollo

Idempotencia en APIs REST: cómo evitar operaciones duplicadas

Felipe••21 min de lectura
Aprende a diseñar reintentos seguros con Idempotency-Key, PostgreSQL y TypeScript: concurrencia, persistencia, expiración y límites del diseño.

El usuario pulsa «Comprar». Tu API crea el pedido y confirma la transacción, pero la conexión se corta antes de entregar la respuesta. El navegador muestra un error. El usuario vuelve a pulsar el botón y aparece un segundo pedido.

El servidor funcionó; la comunicación falló. Desde el cliente, un timeout no permite distinguir entre «la operación nunca comenzó» y «terminó, pero no recibí el resultado». Esa incertidumbre vuelve peligrosos los reintentos.

La idempotencia permite repetir una operación sin multiplicar su efecto previsto. Vamos a entender qué significa en HTTP y a construir un ejemplo con Node.js, TypeScript y PostgreSQL que conserve esa propiedad entre procesos y reinicios. También veremos dónde termina su garantía: crear una fila y cobrar en un proveedor externo son problemas distintos.

Repetir una intención sin repetir su efecto

Imagina dos instrucciones sobre un carrito:

  • «Deja la cantidad en 3». Repetirla mantiene la cantidad en 3.
  • «Añade 3 unidades». Repetirla acumula unidades: 3, 6, 9…

La primera es idempotente respecto de ese estado; la segunda no. No significa que podamos omitir las reglas del negocio ni ignorar cambios realizados por otras personas entre peticiones.

En HTTP importa el efecto solicitado en el servidor, no que cada respuesta tenga exactamente los mismos bytes. Borrar un recurso puede devolver 204 inicialmente y 404 después; sigue ausente. Registrar cada intento en un log tampoco contradice esa propiedad. RFC 9110, sección 9.2.2.

Qué aportan los métodos HTTP

MétodoIdempotente por su semánticaUso habitual
GET, HEAD, OPTIONSSíConsultar representaciones o capacidades
PUTSíCrear o reemplazar en una URI determinada
DELETESíEliminar la asociación del recurso con su URI
POSTNo, en generalProcesar una operación o crear un recurso
PATCHNo, en generalAplicar modificaciones parciales

Los métodos seguros, como GET, no solicitan cambios de estado. PUT y DELETE sí pueden modificarlo y son idempotentes. Una API debe respetar esas semánticas; llamar GET a un endpoint que crea pedidos no lo vuelve seguro. RFC 9110, propiedades de los métodos.

Un PATCH que asigna un valor puede diseñarse como idempotente; uno que incrementa un contador normalmente no. Su comportamiento depende de la operación concreta, no solo del verbo. RFC 5789, sección 2.

Por su parte, una API puede añadir un contrato de idempotencia a un POST. Los clientes genéricos no deben presumir que existe: hay que documentarlo.

La respuesta perdida y los reintentos automáticos

Este es el caso que resolverá nuestro laboratorio:

sequenceDiagram
    participant C as Cliente
    participant A as API
    participant D as PostgreSQL

    C->>A: POST pedido, clave K
    A->>D: Guardar pedido y resultado en una transacción
    D-->>A: COMMIT confirmado
    A--xC: Respuesta 201 perdida
    Note over C: Resultado desconocido
    C->>A: Repetir pedido con la misma clave K
    A->>D: Consultar resultado de K
    D-->>A: Pedido ya creado
    A-->>C: 201 y resultado almacenado

Un reintento puede venir del usuario, de un cliente HTTP o de un trabajo en segundo plano. Deshabilitar un botón evita algunos dobles clics, pero no controla esos otros caminos.

RFC 9110 desaconseja reintentar automáticamente métodos no idempotentes salvo que el cliente conozca una garantía adicional o pueda determinar que la petición original no se aplicó. Un timeout por sí solo no demuestra esto último. RFC 9110, sección 9.2.2.

En un contrato que sí permita reintentos, define errores recuperables, un límite de intentos y una espera creciente con variación aleatoria —jitter— para evitar que muchos clientes vuelvan simultáneamente. Respeta Retry-After cuando corresponda. Agotar esos intentos debe conducir a consultar o reconciliar el estado, no a crear otra operación a ciegas.

Una clave identifica la operación lógica

Idempotency-Key permite asociar varios intentos con una misma intención. El cliente genera una clave al iniciar la operación y la conserva durante sus reintentos:

POST /pedidos HTTP/1.1
Content-Type: application/json
Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"

{"sku":"LIBRO-TS","cantidad":2}

Al revisar este artículo, la propuesta de IETF está en draft-08, publicado el 22 de octubre de 2026: es trabajo en curso, no un RFC aprobado. Define una cadena estructurada HTTP, de ahí las comillas del ejemplo, y contempla claves únicas, huellas de solicitudes y políticas de expiración. Borrador Idempotency-Key.

La implementación siguiente adopta un perfil deliberadamente acotado: UUID entre comillas. No es un parser general de campos estructurados ni una implementación completa del borrador. Otras APIs pueden usar convenciones distintas: sigue su documentación.

Dos compras deliberadas del mismo producto necesitan claves distintas. Por eso no usamos el hash del cuerpo como clave: dos cuerpos iguales pueden representar intenciones legítimamente diferentes. Tampoco generamos un UUID dentro de cada reintento; eso anularía la deduplicación. Si el cliente puede reiniciarse, debe conservar la clave junto a su operación pendiente.

Para evitar cruces entre usuarios y endpoints, nuestro servidor identifica una solicitud mediante:

(cuenta autenticada, operación versionada, clave)

La cuenta debe provenir de la autenticación, nunca de una cabecera arbitraria elegida por quien llama. La clave no es un secreto ni un permiso para recuperar respuestas ajenas. Sigue siendo necesario autenticar y autorizar cada petición, incluso cuando se devuelve un resultado anterior.

Qué guardaremos y qué garantizamos

El laboratorio creará pedidos ficticios. No cobrará, descontará stock ni enviará correos. La base de datos contendrá el pedido y un registro con la clave, una huella de los datos validados, el código HTTP, el cuerpo de respuesta y su vencimiento.

La decisión central es guardar el efecto de negocio y su resultado idempotente en la misma transacción. Evitamos esta secuencia peligrosa:

1. Crear el pedido y confirmar.
2. El proceso se cae.
3. Nunca se registra que la clave ya fue procesada.
4. El reintento crea otro pedido.

Nuestro contrato será el siguiente; los tiempos y códigos son decisiones del ejemplo, no una regla universal:

SituaciónRespuesta del laboratorio
Clave nueva y datos válidosCrear y guardar 201 con el resultado
Misma clave y mismos datos, aún vigenteRepetir el 201 y el cuerpo guardados
Misma clave con otros datos422, sin crear otro pedido
Clave o cuerpo inválidos400, sin reservar la clave
Espera por bloqueo superior a 2 segundos409; conservar la clave para reintentar
Clave vencida410; no ejecutar otra vez
Error de procesamiento o resultado incierto503; conservar la clave y reconciliar o reintentar de forma acotada

El borrador propone 409 para un intento concurrente y 422 para reutilización con otro payload. Aquí permitimos una espera breve antes de devolver el conflicto, para poder recuperar una operación que acaba de completarse. Tratamiento de errores del draft-08.

No guardaremos respuestas de validación ni errores de transacciones revertidas. Es una política concreta. Por ejemplo, Stripe API v1 conserva también respuestas 500 una vez iniciada la ejecución; no se puede extrapolar nuestra política a cualquier servicio. Idempotencia en Stripe.

Laboratorio: Node.js, TypeScript y PostgreSQL

Necesitas Node.js 22 o superior, pnpm y Docker. Crea una carpeta independiente del proyecto de tu aplicación. Las versiones fijadas corresponden al ejemplo ejecutado para este artículo:

mkdir idempotencia-lab
cd idempotencia-lab

printf '%s\n' '{"name":"idempotencia-lab","private":true,"type":"module"}' > package.json

pnpm add -E express@5.3.0 pg@8.23.1 zod@4.6.7
pnpm add -D -E typescript@5.9.3 @types/node@22.20.5 @types/express@5.0.6 @types/pg@8.23.1

mkdir src

Guarda este tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "rootDir": "src",
    "outDir": "dist",
    "types": [
      "node"
    ]
  },
  "include": [
    "src/**/*.ts"
  ]
}

Levanta PostgreSQL limitado a la interfaz local. La contraseña es solo para esta base de laboratorio. El volumen conserva los datos al reiniciar el contenedor:

docker run --name uncodigo-idempotency-db --detach \
  --publish 127.0.0.1:55439:5432 \
  --env POSTGRES_PASSWORD=solo-laboratorio \
  --env POSTGRES_DB=idempotencia \
  --mount type=volume,source=uncodigo-idempotency-lab-data,target=/var/lib/postgresql \
  postgres:18.6

docker exec uncodigo-idempotency-db pg_isready -U postgres -d idempotencia

Espera a que pg_isready indique que acepta conexiones antes de continuar.

1. Definir las tablas

Guarda como esquema.sql:

CREATE TABLE pedidos (
    id uuid PRIMARY KEY,
    cuenta_id text NOT NULL,
    sku text NOT NULL,
    cantidad integer NOT NULL CHECK (cantidad BETWEEN 1 AND 20)
);

CREATE TABLE solicitudes_idempotentes (
    cuenta_id text NOT NULL,
    operacion text NOT NULL,
    clave uuid NOT NULL,
    huella text NOT NULL,
    codigo integer,
    respuesta text,
    creado_en timestamptz NOT NULL DEFAULT now(),
    expira_en timestamptz NOT NULL DEFAULT now() + INTERVAL '24 hours',
    PRIMARY KEY (cuenta_id, operacion, clave),
    CHECK ((codigo IS NULL) = (respuesta IS NULL))
);

Aplícalo una sola vez en la base nueva:

docker exec -i uncodigo-idempotency-db \
  psql -U postgres -d idempotencia < esquema.sql

La clave primaria compuesta impide que dos transacciones registren simultáneamente la misma combinación de cuenta, operación y clave. Esta garantía pertenece a PostgreSQL; un SELECT previo en JavaScript no la reemplaza. Si quieres profundizar, revisa cómo funcionan los índices y restricciones de unicidad.

Los campos de resultado empiezan vacíos dentro de una transacción que aún no se ha confirmado. El programa no confirma hasta completar ambos. No estamos guardando un estado «procesando» para recuperarlo posteriormente con un temporizador.

2. Coordinar el pedido y su resultado

Guarda como src/pedidos.ts:

import { createHash, randomUUID } from 'node:crypto';
import type { Pool } from 'pg';

export type Pedido = { sku: string; cantidad: number };
export type Resultado = { codigo: number; respuesta: string };

export class ErrorContrato extends Error {
  constructor(public readonly codigo: number, mensaje: string) {
    super(mensaje);
  }
}

const OPERACION = 'POST /pedidos:v1';

export async function crearPedido(
  pool: Pool,
  cuentaId: string,
  clave: string,
  pedido: Pedido,
): Promise<Resultado> {
  const huella = createHash('sha256')
    .update(JSON.stringify([1, pedido.sku, pedido.cantidad]))
    .digest('hex');

  const client = await pool.connect();
  let descartarConexion = false;

  try {
    await client.query('BEGIN ISOLATION LEVEL READ COMMITTED');
    await client.query("SET LOCAL lock_timeout = '2s'");
    await client.query("SET LOCAL statement_timeout = '5s'");

    const reserva = await client.query(
      `INSERT INTO solicitudes_idempotentes
         (cuenta_id, operacion, clave, huella)
       VALUES ($1, $2, $3, $4)
       ON CONFLICT (cuenta_id, operacion, clave) DO NOTHING
       RETURNING clave`,
      [cuentaId, OPERACION, clave, huella],
    );

    if (reserva.rowCount === 0) {
      const existente = await client.query<{
        huella: string;
        codigo: number | null;
        respuesta: string | null;
        expirada: boolean;
      }>(
        `SELECT huella, codigo, respuesta,
                expira_en <= clock_timestamp() AS expirada
         FROM solicitudes_idempotentes
         WHERE cuenta_id = $1 AND operacion = $2 AND clave = $3`,
        [cuentaId, OPERACION, clave],
      );

      const anterior = existente.rows[0];

      if (!anterior || anterior.codigo === null || anterior.respuesta === null) {
        throw new Error('Registro idempotente incompleto');
      }

      if (anterior.huella !== huella) {
        throw new ErrorContrato(422, 'La clave ya se usó con otros datos');
      }

      if (anterior.expirada) {
        throw new ErrorContrato(410, 'Clave expirada: consulta el pedido original');
      }

      await client.query('COMMIT');

      return { codigo: anterior.codigo, respuesta: anterior.respuesta };
    }

    const id = randomUUID();

    await client.query(
      `INSERT INTO pedidos (id, cuenta_id, sku, cantidad)
       VALUES ($1, $2, $3, $4)`,
      [id, cuentaId, pedido.sku, pedido.cantidad],
    );

    const respuesta = JSON.stringify({ id, sku: pedido.sku, cantidad: pedido.cantidad });

    await client.query(
      `UPDATE solicitudes_idempotentes
       SET codigo = 201, respuesta = $4
       WHERE cuenta_id = $1 AND operacion = $2 AND clave = $3`,
      [cuentaId, OPERACION, clave, respuesta],
    );

    await client.query('COMMIT');

    return { codigo: 201, respuesta };
  } catch (error) {
    descartarConexion = true;
    await client.query('ROLLBACK').catch(() => undefined);

    throw error;
  } finally {
    client.release(descartarConexion);
  }
}

Hay varias decisiones que conviene entender antes de conectar la ruta HTTP:

  • Huella estable: serializamos una lista con versión, SKU y cantidad ya validados. El orden de las propiedades del JSON entrante no cambia la huella. SHA-256 sirve aquí para comparar contenido; no autentica al cliente. Al añadir campos que alteren el pedido, deben incorporarse al contrato y a la huella.
  • Una conexión por transacción: BEGIN, consultas y COMMIT usan el mismo client. Ejecutarlas mediante llamadas independientes a pool.query podría enviarlas a conexiones distintas. Es una exigencia de node-postgres para sus transacciones.
  • Un solo ganador: el INSERT ... ON CONFLICT disputa la combinación única. Si otra transacción está trabajando con ella, puede esperar su resolución. Si la primera revierte, una segunda puede crear el pedido; si confirma, la segunda recupera el resultado.
  • Consulta posterior separada: en READ COMMITTED, cada sentencia obtiene una nueva vista de los datos confirmados. El SELECT posterior puede ver la fila cuya existencia impidió el INSERT. No fusionamos ambos pasos en una consulta que presuponga la misma visibilidad. PostgreSQL: aislamiento Read Committed.

La garantía depende de que todas las instancias compartan esta base y respeten este camino de escritura. No sustituye las reglas de negocio: dos claves distintas todavía podrían intentar reservar el mismo asiento, algo que exige su propia restricción o coordinación.

Si se pierde la conexión mientras llega la confirmación del COMMIT, el resultado puede ser incierto para Node. Intentar ROLLBACK no deshace una transacción ya confirmada. Por eso el error conserva la clave: el siguiente intento debe consultar el registro durable, no inventar una operación nueva.

3. Exponer la ruta HTTP

Guarda como src/server.ts:

import express, { type ErrorRequestHandler } from 'express';
import { Pool } from 'pg';
import { z } from 'zod';

import { crearPedido, ErrorContrato } from './pedidos.js';

if (!process.env.DATABASE_URL) {
  throw new Error('Falta DATABASE_URL');
}

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
  max: 5,
  connectionTimeoutMillis: 2000,
});

pool.on('error', () => console.error('Conexión de base de datos interrumpida'));

const pedidoSchema = z.object({
  sku: z.string().regex(/^[A-Z0-9-]{1,32}$/),
  cantidad: z.number().int().min(1).max(20),
}).strict();

const app = express();

app.disable('x-powered-by');
app.use((_req, res, next) => {
  res.set('Cache-Control', 'no-store');
  next();
});
app.use(express.json({ limit: '8kb' }));

app.post('/pedidos', async (req, res) => {
  // Perfil del laboratorio: UUID entre comillas, como cadena estructurada HTTP.
  const coincidencia = /^"([0-9a-f-]{36})"$/i.exec(req.get('Idempotency-Key') ?? '');
  const clave = z.uuid().safeParse(coincidencia?.[1]?.toLowerCase());
  const pedido = pedidoSchema.safeParse(req.body);

  if (!clave.success || !pedido.success) {
    res.status(400).json({ error: 'Clave o cuerpo inválidos' });
    return;
  }

  // Cuenta fija exclusivamente para el laboratorio local, sin autenticación.
  const cuentaId = 'cuenta-demo';
  const resultado = await crearPedido(pool, cuentaId, clave.data, pedido.data);

  res.status(resultado.codigo).type('application/json').send(resultado.respuesta);
});

const manejarError: ErrorRequestHandler = (error, _req, res, _next) => {
  if (error instanceof ErrorContrato) {
    res.status(error.codigo).json({ error: error.message });
    return;
  }

  if (error?.type === 'entity.parse.failed' || error?.type === 'entity.too.large') {
    res.status(error.type === 'entity.too.large' ? 413 : 400)
      .json({ error: 'Cuerpo JSON inválido o demasiado grande' });
    return;
  }

  if (error?.code === '55P03') {
    res.set('Retry-After', '1').status(409)
      .json({ error: 'Conflicto temporal; reintenta con la misma clave' });
    return;
  }

  res.set('Retry-After', '1').status(503)
    .json({ error: 'Resultado no confirmado; conserva la misma clave' });
};

app.use(manejarError);

const port = Number(process.env.PORT ?? 3000);
const server = app.listen(port, '127.0.0.1', () => {
  console.info(`Laboratorio en http://127.0.0.1:${port}`);
});

process.on('SIGTERM', () => {
  server.close(() => { void pool.end(); });
});

La cuenta fija y la escucha en 127.0.0.1 delimitan un laboratorio sin autenticación. Antes de integrar el patrón en una API real, reemplaza esa cuenta por la identidad autenticada y verifica permisos antes de ejecutar o recuperar resultados. Este servidor no es una plantilla completa de producción.

Zod rechaza cuerpos fuera del contrato, incluidos campos adicionales. Así evitamos que una entrada ignorada por el negocio termine asociada a una interpretación ambigua de la clave. La validación en tiempo de ejecución sigue siendo necesaria aunque el código esté escrito en TypeScript.

En Express 5, los rechazos de una ruta async llegan al middleware de errores. Allí devolvemos mensajes acotados, sin consultas SQL ni detalles internos. Documentación de errores de Express.

Los límites de bloqueo y de sentencia evitan esperas SQL indefinidas en este ejemplo; no equivalen a un plazo total de la petición. En producción también necesitas límites de concurrencia, observabilidad y una política de timeouts del servicio. Un 503 repetido exige investigar, no un bucle infinito del cliente.

4. Ejecutar y comprobar un reintento

Compila y arranca el servidor:

pnpm exec tsc

DATABASE_URL='postgresql://postgres:solo-laboratorio@127.0.0.1:55439/idempotencia' \
  node dist/server.js

En otra terminal, envía esta petición y repite exactamente el mismo comando:

curl -i http://127.0.0.1:3000/pedidos \
  -H 'Content-Type: application/json' \
  -H 'Idempotency-Key: "8e03978e-40d5-43e8-bc93-6894a57f9324"' \
  --data '{"sku":"LIBRO-TS","cantidad":2}'

Ambas respuestas deben ser 201, con el mismo id y el mismo cuerpo. Si cambias la cantidad a 3 manteniendo la clave, debe aparecer 422. No incluyo un UUID de salida inventado: el servidor generará el de tu pedido.

Puedes consultar el efecto real:

docker exec uncodigo-idempotency-db psql -U postgres -d idempotencia \
  -c 'SELECT id, cuenta_id, sku, cantidad FROM pedidos;'

Detén Node y vuelve a arrancarlo con el mismo comando. Mientras la clave siga vigente y mantengas la base, repetirla debe recuperar el resultado anterior.

5. Probar concurrencia entre dos procesos

Inicia una segunda instancia en otra terminal, conectada a la misma base:

PORT=3001 \
  DATABASE_URL='postgresql://postgres:solo-laboratorio@127.0.0.1:55439/idempotencia' \
  node dist/server.js

Guarda como concurrencia.mjs y ejecuta node concurrencia.mjs:

import { randomUUID } from 'node:crypto';

const clave = randomUUID();
const cuerpo = JSON.stringify({ sku: 'LIBRO-TS', cantidad: 2 });

console.log('Conserva esta clave para cualquier reintento:', clave);

const resultados = await Promise.all(
  Array.from({ length: 10 }, async (_, indice) => {
    const puerto = indice % 2 === 0 ? 3000 : 3001;
    const respuesta = await fetch(`http://127.0.0.1:${puerto}/pedidos`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Idempotency-Key': JSON.stringify(clave),
      },
      body: cuerpo,
    });

    return { status: respuesta.status, cuerpo: await respuesta.text() };
  }),
);

console.table(resultados);

const exitosos = resultados.filter((resultado) => resultado.status === 201);
const ids = exitosos.map((resultado) => JSON.parse(resultado.cuerpo).id);

console.log('Identificadores distintos en respuestas 201:', new Set(ids).size);

Cada ejecución de este archivo representa una nueva operación lógica; los diez intentos de esa ejecución comparten una sola clave. Si aparece 409 por contención, espera según el contrato y reintenta con la clave que imprime el programa; no vuelvas a ejecutar el archivo para «arreglarlo», porque eso generaría otra.

En las pruebas de preparación, con Node.js 22 y PostgreSQL 18, diez peticiones repartidas entre dos procesos devolvieron 201 y un único identificador. También se comprobaron rechazo de datos distintos, aislamiento por cuenta, conflicto temporal, expiración sin duplicación y persistencia tras reiniciar Node y PostgreSQL.

Además, se provocó un fallo al guardar la respuesta: se revirtieron tanto el pedido como su clave. Otra prueba terminó un proceso después del COMMIT sin entregar el resultado; el reintento recuperó el pedido. Son comprobaciones funcionales locales, no una prueba de carga ni de recuperación ante pérdida física de la base de datos.

Expiración: cuándo deja de valer la promesa

Las 24 horas del esquema son una política didáctica, no una recomendación universal. La ventana apropiada depende de cuánto puede tardar un cliente en reintentar, trabajos pendientes, dispositivos desconectados, obligaciones de retención y costo de almacenamiento.

Este laboratorio vence el derecho a recuperar la respuesta automáticamente, pero no elimina la fila ni permite reutilizar la clave. Devuelve 410. Para observarlo, puedes adelantar el vencimiento de la clave utilizada en el ejemplo de curl:

UPDATE solicitudes_idempotentes
SET expira_en = now() - INTERVAL '1 second'
WHERE cuenta_id = 'cuenta-demo'
  AND operacion = 'POST /pedidos:v1'
  AND clave = '8e03978e-40d5-43e8-bc93-6894a57f9324';

Ejecuta ese SQL con psql dentro del contenedor y repite la petición: no debe aparecer otro pedido. Si desconoces el resultado original, consulta el estado de negocio antes de iniciar una nueva operación. El laboratorio permite inspeccionarlo por SQL; una aplicación real necesita un mecanismo de consulta autorizado.

No hay una tarea de limpieza automática en el ejemplo. En producción puedes separar la retención de respuestas completas de una marca mínima de deduplicación, pero debes definir cuánto vive cada una. Borrar todo el registro permite que la misma clave vuelva a parecer nueva.

Stripe API v1, por ejemplo, documenta que puede eliminar claves con al menos 24 horas y que reutilizar una clave eliminada genera una nueva solicitud. Es su contrato, no una garantía perpetua. Retención de claves de Stripe.

Cuando el negocio no admite duplicados más allá de esa ventana, añade una identidad estable propia: una referencia única de orden de compra, por ejemplo. Debe existir una restricción que la proteja; un TTL por sí solo no conserva esa regla.

Por qué un Map en memoria no alcanza

Un Map<string, Resultado> puede servir para explicar la idea, pero tiene límites operativos claros:

  • Cada réplica conserva un mapa distinto. Un reintento dirigido a otra instancia no encuentra el resultado.
  • Un reinicio pierde las claves y abre nuevamente la posibilidad de duplicación.
  • Dos tareas pueden comprobar «no existe» antes de que cualquiera registre el resultado, especialmente si hay un await entre ambos pasos.
  • Guardar el pedido en PostgreSQL y después escribir en memoria sigue dejando el hueco entre dos acciones independientes.

Mover el mapa a Redis comparte el estado, pero no vuelve atómicos por sí solos el cambio en PostgreSQL y el registro en Redis. Incluso adquirir una clave con SET NX exige resolver caídas, expiración del bloqueo y el intervalo entre confirmar el negocio y guardar su resultado.

Aquí usamos la restricción única y la transacción de la misma base que conserva el pedido. En producción, esa base también necesita almacenamiento persistente, respaldos y recuperación coherente. Si restauras pedidos y claves desde momentos distintos, puedes perder la correspondencia que sostiene el diseño. Un volumen local de Docker solo resuelve una parte de esa persistencia.

Pagos, reservas y efectos fuera de la transacción

La idea se aplica a distintas operaciones, pero el alcance importa:

CasoIntención que debe mantenerseControl adicional
Crear un pedidoRecuperar el mismo pedido al reintentarRestricción de la referencia de negocio si debe ser única
Reservar un asientoRepetir la misma solicitud de reservaEvitar que distintas solicitudes ocupen el mismo asiento
Iniciar un cobroNo cobrar otra vez por un intento inciertoIdempotencia y consulta de estado del proveedor

Nuestro COMMIT no puede incluir de forma automática una llamada HTTP a un procesador de pagos. Si el cobro se completa y después falla la transacción local, un ROLLBACK no devuelve ese dinero.

Una estrategia es registrar el cambio de negocio y un trabajo pendiente en una outbox transaccional: una tabla de mensajes escritos dentro de la misma transacción. Un trabajador los envía después. La entrega puede repetirse, así que el consumidor o proveedor todavía debe deduplicar mediante una identidad estable. Patrón transactional outbox de AWS.

Para un cobro, conserva también la clave enviada al proveedor y su identificador de operación. Ante un resultado incierto, consulta o reconcilia su estado según el contrato de esa integración. Una outbox organiza la entrega pendiente; no convierte dos sistemas independientes en una única transacción ni promete ejecución «exactamente una vez» de extremo a extremo.

Errores que vuelven a abrir la duplicación

  1. Generar otra clave en cada intento. El servidor no puede relacionarlos con la intención original.
  2. Deduplicar únicamente por cuerpo. Confundes dos compras legítimas con un reintento.
  3. Consultar y después insertar sin restricción única. Dos solicitudes pueden superar la comprobación inicial.
  4. Confirmar el pedido antes de registrar el resultado. Una caída entre ambos pasos deja una operación sin memoria de idempotencia.
  5. Reutilizar una clave con otros datos. Debe rechazarse para no devolver el resultado de otra intención.
  6. Compartir resultados sin comprobar identidad y permisos. La deduplicación no autoriza acceso a información.
  7. Eliminar claves demasiado pronto o reiniciarlas por timeout. Un trabajo anterior puede haber terminado o seguir ejecutándose.
  8. Reintentar cualquier error indefinidamente. Puedes agravar una sobrecarga o repetir un fallo permanente.
  9. Llamar «exactamente una vez» a una garantía local. La entrega de mensajes, los servicios externos y la restauración de datos añaden otros límites.

Diseñar alrededor del resultado desconocido

Antes de añadir una cabecera, identifica qué efecto no puede repetirse, quién asigna su identidad y dónde se confirma de manera durable. Después decide cómo recuperar el resultado, qué ocurre con las peticiones simultáneas y cuándo termina la garantía.

Para una operación enteramente contenida en una base relacional, una clave compuesta y una transacción pueden ser suficientes. Cuando el efecto cruza sistemas, necesitarás conservar esa identidad a lo largo del flujo y disponer de una forma de reconciliar resultados inciertos. Esa decisión vale más que añadir reintentos a una operación cuyo estado no puedes averiguar.

Para eliminar exclusivamente los datos del laboratorio, detén los procesos de Node y ejecuta:

docker rm -f uncodigo-idempotency-db
docker volume rm uncodigo-idempotency-lab-data

Referencias

#idempotencia#API REST#Idempotency-Key#Node.js#TypeScript#PostgreSQL#reintentos

Artículos relacionados