Idempotencia, retries y garantías de entrega | Nicolás Garzón
En un sistema distribuido, un error observado no demuestra que la operación no ocurrió. La idempotencia permite repetir una intención sin duplicar su efecto; la deduplicación reconoce mensajes repetidos; la reconciliación resuelve estados que no pueden conocerse de inmediato.
La red puede fallar después de que un servidor completó una operación.
Texto
Copiar cliente envía request
→ servidor confirma pedido
→ respuesta se pierde
→ cliente observa timeoutEl cliente tiene dos riesgos:
No reintentar y dejar al usuario sin saber qué pasó.
Reintentar y crear un segundo pedido.
La solución no es “no usar retries”. Es dar identidad a la intención y diseñar el efecto para reconocer repeticiones.
Una operación es idempotente cuando repetir la misma intención produce el mismo efecto observable relevante.
Texto
Copiar confirmar pedido X una vez
≈ confirmar pedido X varias veces con la misma intenciónNo significa necesariamente:
Que no se ejecute código otra vez.
Que la respuesta sea byte por byte igual.
Que no cambie metadata como timestamps de acceso.
Que cualquier request con el mismo payload sea la misma intención.
La identidad debe ser explícita.
Una asignación como status = 'cancelled' parece idempotente. Sin embargo, la operación completa puede enviar correos, emitir eventos o devolver dinero.
Texto
Copiar actualizar estado: idempotente
+ enviar reembolso: no idempotente sin controlDebes evaluar el efecto completo y sus fronteras externas.
Una clave identifica una intención lógica.
JavaScript
Copiar POST / payments
Idempotency- Key: 90f3f2ab- ... El servidor asocia la clave con:
Actor o tenant.
Operación.
Hash del payload.
Estado de procesamiento.
Resultado.
Fecha de expiración.
Texto
Copiar 1. recibir clave y request
2. buscar registro por tenant + operación + clave
3. si no existe, reservar la clave
4. ejecutar operación
5. persistir resultado
6. responder
7. si se repite, devolver resultado previo
processing.
succeeded.
failed_retryable.
failed_permanent.
unknown o requires_reconciliation.
Dos requests simultáneos con la misma clave no deben ejecutar ambos.
SQL
Copiar INSERT INTO idempotency_records (
tenant_id,
operation,
idempotency_key,
request_hash,
status
)
VALUES ( :tenant, :operation, :key , :hash , 'processing' )
ON CONFLICT DO NOTHING; Después se comprueba si el insert creó la fila.
Un lock solo en memoria no protege múltiples instancias ni reinicios.
Una clave repetida con otro payload no debe aceptarse silenciosamente.
Texto
Copiar clave K + amount 10.000
clave K + amount 20.000
→ conflicto, no devolver el resultado anterior como si fuera equivalenteGuarda un hash normalizado de campos que definen la intención.
La normalización debe ser estable: orden de propiedades, valores por defecto y campos irrelevantes pueden alterar hashes accidentalmente.
Código de estado y body.
ID del recurso creado.
Estado lógico para reconstruir respuesta.
Error permanente.
No almacenes secretos o respuestas enormes sin necesidad.
Si el formato de API evoluciona, guardar el recurso y regenerar respuesta puede ser más flexible; guardar la respuesta exacta conserva compatibilidad de la repetición. La elección depende del contrato.
Si llega una repetición mientras la primera sigue ejecutándose:
Esperar brevemente.
Responder 409 o 202 con estado.
Devolver un recurso de polling.
Suscribirse al mismo resultado.
No ejecutes una segunda operación solo porque la primera tarda.
También necesitas detectar registros processing abandonados por crash y reconciliarlos.
La clave debe vivir al menos durante la ventana en que el cliente o infraestructura puede reintentar.
Timeouts de clientes.
Backoff máximo.
Webhooks tardíos.
Replays de broker.
Retención contractual del proveedor.
Riesgo de duplicación.
Un TTL demasiado corto reabre la posibilidad de repetir el efecto. Uno demasiado largo consume almacenamiento y puede bloquear una intención legítimamente nueva si el cliente reutiliza claves.
El mismo cliente puede intentar dos compras iguales. Usar solo hash del payload las confundiría.
Texto
Copiar compra A: mismo producto y monto
compra B: mismo producto y monto
→ son intenciones distintasEl cliente genera una clave nueva para cada intención y conserva la misma durante retries.
Deben ser seguros e idempotentes según semántica HTTP, aunque métricas o caches puedan cambiar internamente.
Expresa reemplazar un recurso conocido y suele ser idempotente si los efectos secundarios también lo son.
Repetir puede mantener el recurso ausente, pero respuestas pueden variar entre 204 y 404.
No es idempotente por defecto, pero puede hacerse seguro para retries con idempotency keys.
El verbo no sustituye diseñar efectos externos.
Retry vuelve a intentar una operación después de un fallo potencialmente transitorio.
Qué errores son transitorios.
Quién reintenta.
Cuántas veces.
Con qué backoff y jitter.
Dentro de qué deadline.
Qué identidad conserva.
Cómo se limita carga.
Timeout de conexión antes de enviar.
429 con Retry-After.
503 temporal.
Deadlock.
Conflicto de serialización.
Nodo no disponible.
Payload inválido.
Credenciales incorrectas.
Regla de negocio rechazada.
Recurso incompatible.
Quota agotada permanentemente.
Timeout de lectura después de enviar escritura.
Conexión cortada durante respuesta.
Crash después de efecto y antes de registrar resultado.
Los ambiguos necesitan consulta o reconciliación, no retry ciego.
Texto
Copiar base 100 ms
intento 1: ~100 ms
intento 2: ~200 ms
intento 3: ~400 msEl jitter evita que miles de clientes repitan simultáneamente.
Respeta Retry-After cuando el proveedor lo ofrece.
El deadline total importa más que cada timeout aislado.
El retry debe vivir cerca de la capa que comprende:
Semántica de la operación.
Idempotencia.
Error transitorio.
Presupuesto.
Si cliente, gateway, servicio y SDK reintentan, la multiplicación es invisible.
Documenta qué capa es responsable y desactiva retries redundantes.
El mensaje se procesa cero o una vez.
Puede perderse, pero evita reentrega intencional.
Apropiado para telemetría no crítica o operaciones donde duplicar es peor que perder, aunque debe analizarse impacto.
El broker reentrega hasta recibir ack.
Puede procesarse varias veces.
Es común y exige consumidor idempotente.
Exactamente una actualización dentro del broker.
Exactamente una transacción entre fuente y destino compatibles.
Deduplicación dentro de una ventana.
No garantiza automáticamente que un correo, API externa o transferencia ocurra una sola vez.
La garantía de mensaje no equivale a garantía de efecto en todo el mundo.
Si el consumidor confirma antes de persistir:
Texto
Copiar ack
→ crash
→ mensaje perdidoTexto
Copiar persistir efecto
→ crash antes de ack
→ mensaje se reentregaLa segunda opción prefiere duplicados frente a pérdida y necesita idempotencia.
Problema de doble escritura:
Texto
Copiar guardar pedido en DB
publicar evento en brokerPuede fallar entre ambas.
Dentro de la misma transacción se guarda el cambio de dominio.
Se inserta un registro outbox.
Se confirma la transacción.
Un relay publica registros pendientes.
Marca o elimina según política.
SQL
Copiar BEGIN ;
UPDATE orders
SET status = 'confirmed'
WHERE id = :order_id;
INSERT INTO outbox (
event_id,
aggregate_id,
event_type,
payload,
created_at
) VALUES ( . . . ) ;
COMMIT ; La base garantiza que pedido y intención de publicar aparecen juntos.
El relay puede publicar y fallar antes de marcar como enviado.
Texto
Copiar publish exitoso
→ crash
→ registro sigue pending
→ publish duplicadoPor eso consumidores deben deduplicar por event_id.
No vendas outbox como exactly-once global.
Polling.
Change Data Capture.
Trigger/log específico.
Orden por agregado.
Batching.
Backpressure.
Reintentos.
Retención.
Poison events.
Observabilidad.
Múltiples workers sin publicar incorrectamente.
El consumidor registra el ID del evento junto con su efecto local.
SQL
Copiar BEGIN ;
INSERT INTO inbox ( consumer, event_id)
VALUES ( :consumer, :event_id)
ON CONFLICT DO NOTHING;
UPDATE projections . . . ;
COMMIT ; La deduplicación y el cambio deben estar en la misma transacción. Si se guardan por separado, puede marcarse procesado sin aplicar efecto o viceversa.
Una tabla global de IDs puede crecer indefinidamente.
Consumidor.
Tenant.
Event ID.
Retención.
Particionamiento.
Riesgo de replays antiguos.
No elimines IDs antes de la retención máxima del broker o ventana de replay sin aceptar duplicados.
Asignar un estado definitivo o insertar con clave única.
Guardar una idempotency key o processed event ID.
Prefiere invariantes del dominio cuando existen, pero no fuerces una clave natural que confunda intenciones distintas.
Un evento duplicado tiene el mismo event_id.
Dos eventos diferentes pueden describir la misma entidad y ser ambos válidos:
Texto
Copiar StockAdjusted event 1: +5
StockAdjusted event 2: +5Deduplicar por payload eliminaría una operación real. Deduplica por identidad del mensaje o comando.
Deduplicar no corrige desorden.
Texto
Copiar v10 OrderConfirmed
v11 OrderCancelled
llega primero v11
luego v10El consumidor necesita versión o secuencia:
Ignorar versiones antiguas.
Bufferizar hasta completar huecos.
Reconsultar autoridad.
Aplicar una operación con merge seguro.
El orden global es costoso. Normalmente se necesita por agregado o clave.
Una proyección puede guardar last_version.
SQL
Copiar UPDATE order_projection
SET status = :status ,
version = :new_version
WHERE order_id = :id
AND version = :expected_previous; Si falla, existe duplicado, desorden o hueco. No lo ignores silenciosamente.
Un webhook es mensajería entre organizaciones mediante HTTP.
Leer body crudo cuando la firma depende de bytes exactos.
Verificar firma.
Validar timestamp y tolerancia de replay.
Identificar proveedor y evento.
Persistir inbox.
Responder rápido.
Procesar asincrónicamente.
Reconciliar con API del proveedor.
El proveedor tiene timeout y puede reintentar. Hacer todo el trabajo antes del 2xx aumenta duplicación.
La firma prueba autenticidad e integridad según secreto y esquema. No prueba que el evento sea nuevo; usa event ID y timestamp.
Un webhook payment.refunded puede llegar antes de payment.succeeded por retries diferentes. Consulta estado autoritativo o usa versión/precedencia.
Texto
Copiar pending_payment
→ paid
→ refunded
Recibir evento con event_id y payment_id.
Verificar firma.
Insertar en inbox.
Si ya existe, responder 200 sin repetir efecto.
Consultar o validar versión del pago.
Actualizar pedido solo si transición es válida.
Guardar outbox para OrderPaid.
Confirmar transacción.
Responder.
Si el estado es ambiguo, se marca para reconciliación.
Incluso con webhooks y retries, pueden perderse eventos o existir bugs.
Texto
Copiar pagos locales pending o recientes
→ consultar proveedor
→ comparar
→ corregir
→ emitir evento faltante
→ auditar
Idempotente.
Paginado.
Reanudable.
Limitado.
Observable.
Seguro ante cambios concurrentes.
A veces el efecto puede protegerse con una clave única.
Ejemplo de crédito por evento:
SQL
Copiar INSERT INTO credits ( account_id, source_event_id, amount)
VALUES ( :account, :event, :amount)
ON CONFLICT ( source_event_id) DO NOTHING; Aunque el mensaje se procese varias veces, el crédito se inserta una vez dentro de esa base.
Un efecto externo sigue necesitando su propia identidad.
El registro queda processing. Usa lease, timeout y reconciliación. No ejecutes otra vez sin conocer estado.
Devuelve conflicto y registra posible error del cliente.
El relay está detenido o lento. Alerta por edad, no solo cantidad. Particiona y aplica retención.
Un replay antiguo vuelve a aplicar efectos. Alinea retención con broker y políticas de replay.
Falla siempre por schema o dato. Después de intentos limitados pasa a DLQ con contexto, sin bloquear partición indefinidamente.
Si el proveedor no soporta idempotencia, reduce retries, persiste intención antes, consulta por referencia o requiere revisión manual.
Si mantiene el mismo event_id, es duplicado. No uses timestamp como identidad.
Se pierde al reiniciar y no coordina instancias.
Confunde operaciones iguales pero legítimamente separadas.
Puede perder el efecto tras crash.
Existe ventana de evento perdido.
No cubre efectos fuera de su frontera.
Llena colas y oculta necesidad de corregir datos o contrato.
Una proyección termina en estado anterior aunque no existan duplicados.
Los estados ambiguos se acumulan hasta que un usuario reclama.
Nuevas claves.
Hits repetidos.
Conflictos de hash.
Registros processing antiguos.
TTL y limpieza.
Pendientes.
Edad máxima.
Tasa de publicación.
Reintentos.
Errores por tipo.
Duplicados.
Desorden.
Huecos de versión.
DLQ.
Pagos pending.
Pedidos duplicados.
Diferencias con proveedor.
Tiempo de reconciliación.
Enviar requests simultáneos con misma clave.
Repetir con payload diferente.
Crash después del efecto y antes de respuesta.
Crash del relay después de publicar.
Reentrega del broker.
Evento fuera de orden.
Replay antiguo.
Webhook con firma inválida.
Provider timeout después de procesar.
Reconciliación con estados divergentes.
TTL expirado.
DLQ y replay manual.
Las pruebas deben controlar puntos de fallo, no solo repetir el camino feliz.
Creación de pedidos.
Pagos y transferencias.
Reservas.
APIs expuestas a retries.
Jobs reiniciables.
Webhooks.
No son necesarias para cada lectura o función pura.
Un timeout no prueba que el efecto no ocurrió.
La idempotencia identifica una intención, no solo un payload.
La deduplicación debe ser persistente y atómica con el efecto.
At-least-once es común y requiere consumidores idempotentes.
Exactly-once debe acotarse a una frontera concreta.
Outbox evita perder la intención de publicar, pero puede duplicar mensajes.
Inbox controla reentregas; versiones controlan desorden.
Reconciliación resuelve incertidumbre que la mensajería no elimina.
¿Por qué dos requests con el mismo payload pueden ser intenciones distintas?
¿Qué ocurre si el relay publica y se cae antes de marcar el outbox?
¿Por qué inbox y efecto deben guardarse en la misma transacción?
¿Qué diferencia existe entre mensaje duplicado y evento legítimo repetido?
¿Cómo manejarías un timeout de pago cuando el proveedor no devolvió respuesta?
Ver respuestas orientativas
Porque un usuario puede realizar dos compras iguales; la identidad es la clave de intención.
El evento se publicará otra vez, por lo que el consumidor debe deduplicar.
Para evitar marcar procesado sin efecto o aplicar efecto sin registrar deduplicación.
El duplicado comparte identidad de mensaje; eventos distintos pueden tener contenido similar y ambos ser válidos.
Mantener estado pending, consultar por referencia/idempotency key y reconciliar antes de repetir el efecto.
Observabilidad como capacidad arquitectónica explica cómo rastrear estas operaciones, detectar estados atascados y relacionar síntomas técnicos con impacto de negocio.