Tu servicio se cae a mitad de una petición POST /charge. El cliente ve un timeout y reintenta. Ahora tienes dos cargos. El cliente está enfadado. La base de datos es consistente. Tu lógica de negocio no.
Esto no es un caso extremo. Es el comportamiento por defecto de los sistemas distribuidos. Las redes pierden packages. Los containers son OOM-killed a mitad de una petición. Los load balancers devuelven 502s para peticiones que ya llegaron al backend. Si tu API asume «He recibido un error, así que la operación definitivamente no ocurrió», estás construyendo una fábrica de bugs.
La realidad at-least-once de la que no puedes escapar
HTTP es at-least-once por defecto. TCP reintenta los packages perdidos. Tu cliente HTTP reintenta en timeout. Tu infraestructura reintenta en 5xx. Cada capa asume que la anterior puede fallar y reenvía.
El problema no son los reintentos. El problema son las operaciones non-idempotent.
Un GET es seguro de reintentar porque leer dos veces es lo mismo que leer una. Un POST /charge o POST /orders no lo es. Ejecutarlo dos veces crea dos recursos. Ejecutarlo cero veces pierde una venta. No puedes elegir «exactly once» en una red no fiable. Solo puedes elegir entre «at least once» con deduplicación, o «quizás cero, quizás dos» con corrupción de datos.
La idempotencia es cómo haces que «at least once» sea seguro.
Cómo funcionan realmente las idempotency keys
Stripe popularizó este patrón, pero la idea es más antigua. El cliente genera una clave única (un UUID) y la envía en una cabecera: Idempotency-Key: <uuid>. El servidor almacena la tupla (key, request_body, response) antes de ejecutar el side effect. Si la misma clave llega de nuevo, el servidor devuelve la respuesta almacenada sin volver a ejecutar la operación.
La idea clave: el servidor debe almacenar la clave antes de hacer el trabajo, no después. Si escribes primero la fila del charge y te caes antes de almacenar la clave, el reintento crea un segundo charge. El almacenamiento de la idempotency key y la mutación de negocio deben ser atomics.
En la práctica, esto significa una de dos cosas:
-
El idempotency store y el business store comparten una transaction de base de datos. Insertas en
idempotency_keysychargesen el mismoBEGIN ... COMMIT. Si el commit tiene éxito, ambos existen. Si falla, ninguno lo hace. -
El idempotency store es el business store. Tu tabla
chargestiene una columnaclient_idempotency_keycon una restricciónUNIQUE. El reintento falla en la comprobación de unicidad, y devuelves la fila existente.
La opción 2 es más sencilla y es con la que la mayoría de equipos deberían empezar.
Una implementación funcional con almacenamiento atomic
Aquí tienes un servidor Python mínimo pero completo usando SQLite. La idempotency key vive en la misma transaction que la mutación de negocio. Si el servidor se cae después del COMMIT, el reintento acierta en la caché. Si se cae antes del COMMIT, nada se persiste y el reintento se ejecuta de nuevo de forma segura.
import sqlite3
import json
import uuid
DB_PATH = "/data/inventory.db"
def init_db():
conn = sqlite3.connect(DB_PATH)
conn.execute("""
CREATE TABLE IF NOT EXISTS reservations (
id TEXT PRIMARY KEY,
item_id TEXT NOT NULL,
quantity INTEGER NOT NULL,
idempotency_key TEXT UNIQUE NOT NULL,
created_at INTEGER
)
""")
conn.execute("""
CREATE TABLE IF NOT EXISTS idempotency_responses (
key TEXT PRIMARY KEY,
response_body TEXT NOT NULL,
created_at INTEGER
)
""")
conn.commit()
return conn
def reserve_inventory(conn, idempotency_key, item_id, quantity):
# Step 1: Check for an existing response.
cursor = conn.execute(
"SELECT response_body FROM idempotency_responses WHERE key = ?",
(idempotency_key,)
)
row = cursor.fetchone()
if row:
return json.loads(row[0])
# Step 2: Do the work inside a single transaction.
conn.execute("BEGIN")
# Deduct inventory atomically.
conn.execute(
"UPDATE inventory SET available = available - ? WHERE item_id = ? AND available >= ?",
(quantity, item_id, quantity)
)
if conn.total_changes == 0:
conn.rollback()
return {"error": "insufficient inventory"}
# Record the reservation.
reservation_id = str(uuid.uuid4())
conn.execute(
"INSERT INTO reservations (id, item_id, quantity, idempotency_key, created_at) VALUES (?, ?, ?, ?, strftime('%s','now'))",
(reservation_id, item_id, quantity, idempotency_key)
)
# Cache the response for retries.
response = {"reservation_id": reservation_id, "status": "reserved"}
conn.execute(
"INSERT INTO idempotency_responses (key, response_body, created_at) VALUES (?, ?, strftime('%s','now'))",
(idempotency_key, json.dumps(response))
)
conn.commit()
return response
La tabla idempotency_responses es la red de seguridad. La primera petición ejecuta la mutación, confirma el resultado y almacena en caché la respuesta. Cada petición posterior con la misma clave omite el trabajo y devuelve el JSON en caché. La reserva y la entrada de caché se escriben en la misma transaction, así que o ambas son visibles o ambas están ausentes.
Dónde esto falla: el límite del side effect
Las idempotency keys gestionan peticiones duplicadas del mismo cliente. No gestionan peticiones duplicadas concurrentes de diferentes clientes. Si dos usuarios hacen clic en «Buy» sobre el último artículo en stock, todavía necesitas pessimistic locking o optimistic concurrency control. La comprobación available >= ? del ejemplo anterior es una forma primitiva de esto, pero los sistemas reales de inventario necesitan más.
El problema mayor son los side effects fuera de la transaction. Si cargas una tarjeta mediante Stripe, envías un email mediante SendGrid y escribes en tu base de datos, la idempotency key solo protege la parte de la base de datos. El email podría enviarse dos veces. La tarjeta podría cargarse dos veces si la ventana de idempotency de Stripe expiró. La seguridad real requiere que cada sistema downstream participe.
Por eso Stripe acepta su propia Idempotency-Key en la creación de charges. Ellos deduplican en su capa. Tú deberías hacer lo mismo en la tuya. Pasa la misma clave a cualquier servicio downstream idempotent. Para servicios que no lo soportan, envuelve la llamada en una transaction local o acepta el riesgo.
Colisiones de claves, TTLs y otras trampas operativas
El UUID4 tiene 122 bits de aleatoriedad. La probabilidad de colisión es negligible para cualquier volumen realista. No uses enteros secuenciales, timestamps ni cuerpos de petición hasheados como claves. Un UUID generado por el cliente es el estándar de la industria por algo.
El almacenamiento de claves crece para siempre a menos que expire entradas antiguas. Establece un TTL: 24 horas es el estándar. Después de eso, elimina las claves antiguas. Si un cliente reintenta después del TTL, obtendrá un duplicado. Documenta esto. La ventana de reintentos y el TTL son un contrato de negocio, no un detalle técnico.
El idempotency store debe ser al menos tan disponible como la API. Si tu caché de Redis está caída, no puedes verificar claves. Algunos equipos recurren a «asumir nueva petición», lo que crea duplicados durante interrupciones. Otros rechazan la petición, lo que es más seguro pero crea un modo de fallo diferente. Aquí no hay almuerzo gratis.
El lado del cliente importa igual de mucho
Las idempotency keys del lado del servidor son inútiles si el cliente no las envía. Cada petición mutadora debería generar una clave en el sitio de la llamada y reintentar en timeout con la misma clave:
import uuid
import requests
def safe_post(url, payload, max_retries=3):
key = str(uuid.uuid4())
for attempt in range(max_retries):
try:
resp = requests.post(
url,
json=payload,
headers={"Idempotency-Key": key},
timeout=10,
)
return resp
except requests.Timeout:
if attempt == max_retries - 1:
raise
# Retry with the SAME key. The server deduplicates.
La clave debe generarse una vez por operación lógica, no una vez por intento HTTP. Si generas un nuevo UUID en cada reintento, has perdido el punto. La clave es el contrato que une los reintentos.
FAQ
¿Qué pasa si el cliente es un navegador y el usuario refresca la página?
Un refresco de página crea un nuevo contexto de JavaScript. La nueva petición obtiene una nueva idempotency key a menos que persistas la clave en localStorage o en la URL. La mayoría de equipos no se molestan para flujos no críticos. Para pagos, deberías hacerlo.
¿Deberían las peticiones GET usar idempotency keys?
No. GET ya es seguro por semántica HTTP. Las idempotency keys son para métodos que cambian el estado: POST, PUT, PATCH, DELETE.
¿Puedo usar el hash del cuerpo de la petición como idempotency key?
Solo si el cuerpo es determinista y no contiene timestamps ni valores aleatorios. En la práctica, los UUID generados por el cliente son más sencillos y más fiables.
¿Cuánto tiempo debería conservar las idempotency keys?
Más tiempo que tu ventana de reintentos del cliente más larga. Si los clientes reintentan durante 60 segundos, conserva las claves durante 24 horas. Si los clientes podrían reintentar mañana debido a un batch job, conserva las claves durante una semana.
Haz que tu primera petición sea idempotent hoy
Añade una cabecera Idempotency-Key a cada endpoint mutador. Empieza con una caché de 24 horas. Usa un UUID4. Haz que el almacenamiento sea atomic con tu transaction de negocio. La primera vez que un container muera a mitad de una petición y el cliente reintente, te alegrarás de haberlo hecho. La alternativa es explicarle a tu equipo de finanzas por qué un único cliente tiene diecisiete cargos idénticos.