Un viernes a las 18:40 un cliente pagó dos veces el mismo pedido. Nadie tocó nada raro: el gateway tardó más de la cuenta, nuestro cliente HTTP reintentó por timeout y la segunda petición llegó después de que la primera —que no había muerto— terminara de procesar el cargo. Dos transacciones válidas, un solo pedido.
Por qué los retries duplican
Cualquier sistema con reintentos automáticos entrega al menos una vez (at-least-once). Un timeout no significa que la operación falló: significa que no sabes qué pasó. Si el servidor llegó a procesar y la respuesta se perdió, el reintento es un duplicado con todas las de la ley.
La conclusión incómoda es que la deduplicación no es opcional ni un refinamiento: es parte del contrato de cualquier endpoint con efectos secundarios que viva detrás de un cliente con retries. O sea, de todos.
La clave de idempotencia
El patrón que implementamos es el mismo que exponen Stripe o Adyen: el cliente genera una clave única por operación (no por petición) y la envía en la cabecera Idempotency-Key. El servidor garantiza que dos peticiones con la misma clave producen exactamente un efecto y la misma respuesta.
La pieza crítica es dónde vive la garantía. No puede ser un if en memoria ni una caché: tiene que ser una restricción del almacén de datos, porque dos réplicas del servicio pueden procesar los dos reintentos en paralelo. En MongoDB, un índice único sobre la clave hace el trabajo sucio.
// Reserva atómica de la clave ANTES de tocar el gateway.
// El índice único sobre "key" convierte la carrera en un error controlado.
func (s *Store) Reserve(ctx context.Context, key string, req Hash) (*Op, error) {
op := Op{Key: key, ReqHash: req, Status: StatusPending, CreatedAt: now()}
_, err := s.ops.InsertOne(ctx, op)
if mongo.IsDuplicateKeyError(err) {
existing, _ := s.findByKey(ctx, key)
if existing.ReqHash != req {
return nil, ErrKeyReused // misma clave, cuerpo distinto: rechazar
}
return existing, ErrAlreadyProcessed // devolver respuesta archivada
}
return &op, err
}Los tres estados que importan
- PENDING — la clave está reservada y alguien trabaja: el duplicado espera o recibe 409 con Retry-After.
- SUCCEEDED — respuesta archivada: el duplicado recibe la respuesta original, byte a byte, con 200.
- FAILED — fallo definitivo archivado: el duplicado recibe el mismo error sin re-ejecutar nada.
El matiz que más discusión generó: guardar también el hash del cuerpo de la petición. Si llega la misma clave con un payload distinto, eso no es un retry, es un bug del cliente — y la respuesta correcta es 422, no procesar en silencio cualquiera de las dos versiones.
Lecciones
- La idempotencia se diseña el día uno; añadirla después significa migrar datos con duplicados ya dentro.
- La garantía vive en el almacén (índice único), nunca en la memoria del proceso.
- Archivar la respuesta original es parte del contrato: un retry exitoso debe ser indistinguible del original.
- Las claves caducan (nosotros: 24 h) o la colección crece para siempre.
Desde el despliegue, el sistema ha absorbido más de 5.000 transacciones concurrentes en picos con cero cargos duplicados. El incidente del viernes costó una disculpa y un reembolso; el diseño que lo siguió es probablemente el código con mejor retorno por línea que he escrito.