Idempotencia: por qué un cobro reintentado se cobra dos veces
Un cliente pulsa «Pagar», la conexión tarda, pulsa otra vez y se le cobra dos veces. O tu pasarela reintenta un aviso que sí llegó pero cuya respuesta se perdió y tu sistema apunta el cobro dos veces. En los dos casos el fallo está en tu lado y en los dos el arreglo es el mismo: hacer que repetir una operación no la ejecute dos veces.
Cuándo aparece
No hace falta volumen. Aparece con diez clientes y aparece por estas cuatro vías:
- Doble clic. El botón no se deshabilita, o se deshabilita en el navegador de quien lo probó.
- Reintento del navegador o de la red. Una petición que agota el tiempo de espera puede haberse ejecutado igualmente. El cliente no lo sabe.
- Reintento del proveedor. Toda pasarela seria reintenta un aviso si no recibe una respuesta correcta en unos segundos. Stripe sigue reintentando hasta tres días.
- Reintento de tu propia cola. Un trabajo que falla después de cobrar y antes de marcar que cobró vuelve a entrar y vuelve a cobrar.
Las dos últimas son las que más sorprenden, porque el sistema funciona perfectamente durante meses hasta el día que hay una latencia mala.
Por qué la solución obvia no funciona
El primer intento de casi todo el mundo es comprobar antes de actuar:
¿Existe ya un cobro para este pedido? Si no existe, cóbralo.
Esto no arregla nada y merece la pena entender por qué, porque es el mismo motivo por el que fallan muchos otros arreglos.
Entre la comprobación y la escritura pasa tiempo. Muy poco, pero pasa. Si dos peticiones llegan a la vez —y el doble clic las manda a la vez, por definición— las dos comprueban antes de que ninguna haya escrito, las dos encuentran que no existe y las dos cobran. La ventana es de milisegundos y se acierta más a menudo de lo que sugiere la intuición.
Las dos comprueban, las dos encuentran que no existe, y las dos cobran.
El segundo intento suele ser un candado en el código de la aplicación. Funciona mientras haya una sola instancia. Deja de funcionar el día que hay dos, que suele ser el día que el producto empieza a ir bien.
El patrón: la clave de idempotencia
La operación deja de identificarse por lo que hace y pasa a identificarse por quién la pidió y cuál era.
El cliente genera un identificador único para el intento (el intento, que no es lo mismo que el pedido) y lo manda con la petición. El servidor guarda ese identificador en la misma transacción en la que ejecuta la operación, con una restricción de unicidad en la base de datos. Si llega una segunda petición con la misma clave, la restricción falla y en lugar de ejecutar otra vez se devuelve el resultado de la primera.
Tres detalles que son los que hacen que funcione de verdad:
- La unicidad la impone la base de datos. Es el único punto del sistema donde dos peticiones simultáneas no pueden ganar las dos y el código solo no lo garantiza.
- La clave y el efecto se guardan juntos, en una transacción. Si se guardan por separado, vuelves a tener la ventana que querías cerrar.
- Se guarda también la respuesta. El segundo intento debe recibir exactamente lo mismo que el primero. Para quien llama, la operación tuvo éxito una vez y eso es todo lo que necesita saber.
La unicidad la impone la base de datos, no el código: es el único sitio donde dos peticiones simultáneas no pueden ganar las dos.
Las pasarelas serias ya lo soportan del lado del proveedor: Stripe acepta una clave de idempotencia para que una petición reintentada no ejecute la misma operación dos veces. Eso resuelve tu llamada hacia ellos. No resuelve tu propio registro, que es donde suele estar el problema real: el cargo se hizo una vez y tú lo apuntaste dos.
Los avisos entrantes son el mismo problema al revés
Cuando la pasarela te avisa de que un pago se ha completado, tu endpoint tiene que asumir tres cosas incómodas:
- El mismo aviso puede llegar más de una vez. Es el caso normal.
- Pueden llegar desordenados. El aviso de «suscripción cancelada» puede llegar antes que el de «suscripción actualizada».
- Puede llegar uno de un evento que ya conocías por otra vía, porque tu aplicación ya había procesado la respuesta síncrona.
Se resuelve igual: cada evento del proveedor trae un identificador propio. Guárdalo con restricción de unicidad y procesa dentro de la transacción. Si ya estaba, responde correctamente y no hagas nada más. Responder con un error a un aviso duplicado es un error caro: el proveedor seguirá reintentando durante días.
Y una comprobación que se olvida: verifica la firma del aviso. Un endpoint de avisos de pago sin verificación de firma es un endpoint donde cualquiera puede declarar que ha pagado.
Qué cuesta aplicarlo
Ningún patrón es gratis y este trae facturas pequeñas:
- Una tabla más y una restricción más, que hay que limpiar. Las claves se retienen un tiempo razonable —de horas a días, según el proveedor— y después se purgan.
- Un poco más de latencia. La escritura de la clave está en el camino crítico de la operación.
- Disciplina en el cliente. La clave la genera quien llama y tiene que ser la misma en el reintento. Una clave nueva por intento no protege de nada y es el error de implementación más común.
Sobre esa última: si tu cliente genera un identificador aleatorio en cada envío, has escrito todo el mecanismo para nada. La clave identifica la intención y cada envío de esa intención lleva la misma.
Lo que sigue fallando
Con todo bien hecho, quedan dos casos que conviene tener presentes:
La operación que toca dos sistemas. Cobrar en la pasarela y apuntar en tu base de datos son dos escrituras en dos sitios y no hay transacción que las abarque. Puedes hacer idempotente cada una, pero sigue existiendo el momento en que una ha ocurrido y la otra no. Se maneja reconciliando contra el proveedor de forma periódica: preguntar qué cobros existen realmente y compararlos con los tuyos. Es aburrido, pero es lo único que funciona.
El reembolso. Casi todo el mundo protege el cobro y deja el reembolso sin proteger. Se descubre el día que alguien devuelve dos veces el mismo importe.
Cuándo no merece la pena
Si tu operación no tiene efectos, como una consulta o una búsqueda, es idempotente por naturaleza y no hay nada que hacer.
Y si el efecto es reversible y barato, a veces la reconciliación diaria sale más a cuenta que la protección previa. Enviar dos veces un correo de aviso es molesto; cobrar dos veces es una incidencia con tu cliente y con su banco. El esfuerzo se pone donde el error tiene factura.
Lo que no admite discusión: cualquier operación que mueva dinero, emita un documento legal o comunique algo a un tercero se protege desde el primer día. Añadirlo después obliga a auditar qué pasó en el periodo sin protección y esa auditoría cuesta más que haberlo hecho bien.
Vecinly cobra suscripciones recurrentes en producción, con impagos, cambios de plan y avisos entrantes de la pasarela. Se puede ver en su ficha de caso. Si tienes un prototipo que ya cobra y no sabes si está protegido, SaaS a medida empieza justamente por revisar eso.