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étodo | Idempotente por su semántica | Uso habitual |
|---|---|---|
GET, HEAD, OPTIONS | Sí | Consultar representaciones o capacidades |
PUT | Sí | Crear o reemplazar en una URI determinada |
DELETE | Sí | Eliminar la asociación del recurso con su URI |
POST | No, en general | Procesar una operación o crear un recurso |
PATCH | No, en general | Aplicar 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:
No se pudo mostrar el diagrama. Puedes consultar su definición.
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ón | Respuesta del laboratorio |
|---|---|
| Clave nueva y datos válidos | Crear y guardar 201 con el resultado |
| Misma clave y mismos datos, aún vigente | Repetir el 201 y el cuerpo guardados |
| Misma clave con otros datos | 422, sin crear otro pedido |
| Clave o cuerpo inválidos | 400, sin reservar la clave |
| Espera por bloqueo superior a 2 segundos | 409; conservar la clave para reintentar |
| Clave vencida | 410; no ejecutar otra vez |
| Error de procesamiento o resultado incierto | 503; 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 srcGuarda 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 idempotenciaEspera 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.sqlLa 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 yCOMMITusan el mismoclient. Ejecutarlas mediante llamadas independientes apool.querypodría enviarlas a conexiones distintas. Es una exigencia de node-postgres para sus transacciones. - Un solo ganador: el
INSERT ... ON CONFLICTdisputa 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. ElSELECTposterior puede ver la fila cuya existencia impidió elINSERT. 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.jsEn 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.jsGuarda 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
awaitentre 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:
| Caso | Intención que debe mantenerse | Control adicional |
|---|---|---|
| Crear un pedido | Recuperar el mismo pedido al reintentar | Restricción de la referencia de negocio si debe ser única |
| Reservar un asiento | Repetir la misma solicitud de reserva | Evitar que distintas solicitudes ocupen el mismo asiento |
| Iniciar un cobro | No cobrar otra vez por un intento incierto | Idempotencia 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
- Generar otra clave en cada intento. El servidor no puede relacionarlos con la intención original.
- Deduplicar únicamente por cuerpo. Confundes dos compras legítimas con un reintento.
- Consultar y después insertar sin restricción única. Dos solicitudes pueden superar la comprobación inicial.
- Confirmar el pedido antes de registrar el resultado. Una caída entre ambos pasos deja una operación sin memoria de idempotencia.
- Reutilizar una clave con otros datos. Debe rechazarse para no devolver el resultado de otra intención.
- Compartir resultados sin comprobar identidad y permisos. La deduplicación no autoriza acceso a información.
- Eliminar claves demasiado pronto o reiniciarlas por timeout. Un trabajo anterior puede haber terminado o seguir ejecutándose.
- Reintentar cualquier error indefinidamente. Puedes agravar una sobrecarga o repetir un fallo permanente.
- 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-dataReferencias
- RFC 9110: propiedades de los métodos HTTP e idempotencia.
- RFC 5789: PATCH y sus propiedades.
- IETF: Idempotency-Key HTTP Header Field, draft-08, trabajo en curso.
- PostgreSQL 18: aislamiento Read Committed.
- node-postgres: transacciones.
- Express: manejo de errores.
- Stripe: solicitudes idempotentes y políticas por versión de API.
- AWS: patrón transactional outbox.
