Idempotencia en APIs REST con Spring Boot: cómo evitar cobros duplicados en reintentos de red
Un cliente móvil hace un POST a /pagos. La red se cae justo después de que el servidor procesa el cobro pero antes de que la respuesta llegue de vuelta. El cliente ve un timeout, no un error explícito, y hace lo que cualquier cliente bien programado hace ante un timeout: reintenta. El servidor recibe una segunda petición idéntica, no tiene forma de saber que ya procesó la primera, y cobra otra vez.
Este es el escenario que nos llevó a estandarizar idempotencia en los endpoints de escritura de nuestros servicios Spring Boot que mueven dinero o cualquier operación no repetible. No es un problema exótico: ocurre cada vez que hay una red de por medio entre el cliente y el servidor, y una red siempre puede cortarse en el peor momento posible, que es justo después de que el servidor terminó el trabajo pero antes de que el cliente se entere.
Por qué el problema no se resuelve solo con una restricción en la base de datos
La primera reacción instintiva suele ser: "pongo un UNIQUE sobre el número de orden y ya, la base de datos rechaza el duplicado". Esto evita el cobro doble, que es la mitad del problema, pero introduce uno nuevo: la segunda petición del cliente, la que reintenta porque perdió la respuesta de la primera, ahora recibe un error de restricción violada en lugar de la confirmación que estaba esperando.
@PostMapping("/pagos")
public ResponseEntity<PagoResponse> crearPago(@RequestBody PagoRequest request) {
// UNIQUE sobre numeroOrden evita el cobro duplicado...
Pago pago = pagoRepository.save(new Pago(request.getNumeroOrden(), request.getMonto()));
return ResponseEntity.ok(new PagoResponse(pago.getId(), pago.getEstado()));
}
En el primer intento, esto guarda el pago y devuelve 200 con el id de la transacción. En el segundo intento -el reintento del cliente que nunca vio esa respuesta- la restricción UNIQUE lanza una excepción, Spring la traduce a un DataIntegrityViolationException, y el endpoint termina devolviendo un 409 o un 500 dependiendo de cómo esté mapeado el manejador de errores. El cliente, que solo quería confirmar si su pago se procesó, recibe un error genérico y no tiene forma de distinguir "tu pago falló" de "tu pago ya se procesó, esto es un duplicado". Esa ambigüedad es exactamente lo que la idempotencia tiene que eliminar, y una restricción de unicidad por sí sola no lo hace: evita el efecto secundario duplicado pero no le devuelve al cliente el resultado que necesitaba.
La clave de idempotencia y dónde vive
El patrón estándar es que el cliente genere un identificador único por operación -normalmente un UUID- y lo mande en un header, típicamente Idempotency-Key. Ese identificador representa la intención del cliente de ejecutar esa operación exactamente una vez, sin importar cuántas veces la petición HTTP se repita por reintentos de red.
@Entity
@Table(name = "idempotency_keys")
public class IdempotencyKeyRecord {
@Id
private String key;
private String requestHash;
private Integer statusCode;
@Lob
private String responseBody;
private Instant createdAt;
private Instant expiresAt;
}
La tabla guarda tres cosas que importan: la clave que mandó el cliente, un hash del cuerpo de la petición (para detectar si alguien reutiliza la misma clave con un payload distinto, lo cual es un error del cliente y hay que rechazarlo), y la respuesta completa que el servidor generó la primera vez -código de estado incluido-. Esa última parte es la que resuelve el problema que la restricción UNIQUE sola no resolvía: cuando llega un reintento con una clave ya vista, el servidor no vuelve a ejecutar la lógica de negocio, simplemente devuelve exactamente la misma respuesta que generó la primera vez.
Implementación con un filtro en Spring Boot
Centralizamos esto en un OncePerRequestFilter en lugar de repetir la lógica en cada controlador, porque la mecánica de idempotencia es idéntica sin importar qué endpoint la use.
@Component
public class IdempotencyFilter extends OncePerRequestFilter {
private final IdempotencyKeyRepository repository;
public IdempotencyFilter(IdempotencyKeyRepository repository) {
this.repository = repository;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
if (!requiereIdempotencia(request)) {
chain.doFilter(request, response);
return;
}
String key = request.getHeader("Idempotency-Key");
if (key == null || key.isBlank()) {
response.sendError(HttpStatus.BAD_REQUEST.value(), "Falta el header Idempotency-Key");
return;
}
Optional<IdempotencyKeyRecord> existente = repository.findById(key);
if (existente.isPresent()) {
IdempotencyKeyRecord registro = existente.get();
if (registro.getResponseBody() == null) {
response.sendError(HttpStatus.CONFLICT.value(), "Esta operación ya está en proceso");
return;
}
response.setStatus(registro.getStatusCode());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(registro.getResponseBody());
return;
}
try {
repository.insertPlaceholder(key, hashDe(request));
} catch (DataIntegrityViolationException e) {
// otra petición con la misma clave ganó la carrera y ya está en proceso
response.sendError(HttpStatus.CONFLICT.value(), "Esta operación ya está en proceso");
return;
}
ContentCachingResponseWrapper wrapped = new ContentCachingResponseWrapper(response);
chain.doFilter(request, wrapped);
byte[] cuerpo = wrapped.getContentAsByteArray();
repository.guardarRespuesta(key, wrapped.getStatus(), new String(cuerpo, StandardCharsets.UTF_8));
wrapped.copyBodyToResponse();
}
}
Dos detalles de este filtro importan más que el resto del código. El primero es el insertPlaceholder: insertamos una fila vacía (sin responseBody) antes de ejecutar la lógica de negocio, apoyándonos en que la clave primaria es UNIQUE por definición. Si dos peticiones con la misma clave llegan al mismo tiempo -algo que pasa cuando un cliente móvil reintenta de forma agresiva mientras la primera petición todavía está en vuelo- solo una gana la inserción; la otra recibe la excepción de la base de datos y responde 409 sin haber tocado la lógica de negocio. Sin este placeholder, ambas peticiones concurrentes ejecutarían el cobro antes de que ninguna terminara de guardar su resultado, que es exactamente la condición de carrera que la idempotencia debería prevenir.
El segundo detalle es el ContentCachingResponseWrapper: sin él, no hay forma de leer el cuerpo de la respuesta después de que el controlador ya escribió en el HttpServletResponse, porque el stream de salida no es releíble. El wrapper lo intercepta, permite copiar el contenido a la tabla de idempotencia después de que el controlador terminó, y después lo vuelca al response real con copyBodyToResponse().
Cuánto tiempo mantener la clave
El campo expiresAt existe porque la tabla no puede crecer indefinidamente, pero el valor no debería salir de una convención genérica copiada de otro proyecto. El criterio correcto es cubrir el peor caso realista de reintento del cliente: si un cliente móvil hace backoff exponencial con un límite de reintentos y un timeout de red configurado, la ventana de expiración tiene que ser mayor a la suma de esos tiempos, con margen para que un usuario cierre la app y la vuelva a abrir después de una caída de conexión y el cliente reintente la misma operación con la misma clave que generó antes de que la app se cerrara. Un job programado que borre claves vencidas es suficiente; no hace falta borrar en el mismo request.
El error que sí vimos repetirse
El error que efectivamente encontramos al revisar implementaciones de idempotencia a medias no era la ausencia de una clave de idempotencia, sino aplicarla solo en la capa de base de datos como en el primer ejemplo de este artículo. Ese patrón cubre el caso feliz -nadie reintenta- y falla exactamente en el caso que la idempotencia existe para resolver: el reintento tras una respuesta perdida. Si la única protección es una restricción UNIQUE, el sistema evita el efecto secundario duplicado pero convierte cada reintento legítimo en un error de cliente, y del otro lado de ese error hay una persona o un sistema que no sabe si su pago se procesó o no.
La idempotencia bien implementada no es "evitar que la operación se repita en la base de datos". Es "garantizar que, sin importar cuántas veces llegue la misma petición, el cliente reciba exactamente la respuesta que hubiera recibido si la red nunca hubiera fallado". Esa segunda parte es la que una restricción de unicidad, por sí sola, nunca puede darte.