⏱️ Lectura: 9 min

Imaginá que pagás en línea, la página se traba, hacés clic otra vez… y aparece el miedo de siempre: “¿me habrán cobrado dos veces?”. Ese problema —operaciones que se ejecutan dos veces cuando el cliente reintenta— es uno de los más costosos en sistemas de pagos y APIs financieras. La extensión open source quarkus-http-idempotency, publicada en Quarkiverse y disponible en Maven Central, lo resuelve a nivel del servidor sin que tengas que escribir lógica especial: hace que un POST o PATCH se ejecute exactamente una vez, aunque llegue repetido.

📑 En este artículo
  1. El problema: reintentar no es gratis
  2. La solución: el header Idempotency-Key
  3. Qué es quarkus-http-idempotency
  4. Instalación
  5. Guía de uso rápida
  6. Cómo se comporta en cada caso
  7. Seguro para multiusuario y multi-tenant
  8. Stores: memoria local o Redis distribuido
  9. Configuración
  10. Mejores prácticas
  11. Seguridad y robustez
  12. Preguntas frecuentes
  13. Referencias

Servidores e infraestructura de datos

El problema: reintentar no es gratis

En la web, los reintentos son inevitables: una conexión que se cae, un timeout, un usuario impaciente que hace doble clic, un cliente HTTP configurado para reintentar automáticamente. Cuando la petición es de solo lectura (GET), reintentar es inofensivo. Pero cuando la petición cambia algo —crear un pedido, cobrar una tarjeta, transferir dinero— ejecutarla dos veces puede significar dos cargos, dos pedidos o dos transferencias.

La dificultad es que el cliente muchas veces no sabe si la primera petición llegó. Si el servidor procesó el cobro pero la respuesta se perdió en el camino, el cliente cree que falló y reintenta. El daño ya está hecho.

La solución: el header Idempotency-Key

La industria resolvió esto con un patrón simple y elegante, popularizado por Stripe y formalizado en un borrador del IETF: el header Idempotency-Key.

La idea: el cliente genera una clave única (por ejemplo, un UUID) y la envía con la petición. El servidor recuerda esa clave. Si llega una segunda petición con la misma clave, el servidor no vuelve a ejecutar la operación: simplemente devuelve la respuesta guardada de la primera vez. El efecto secundario ocurre una sola vez; el cliente recibe siempre la misma respuesta.

Ya cubrimos a fondo el concepto y sus casos límite en artículos anteriores. Este artículo se enfoca en la herramienta que lo implementa en Quarkus de forma lista para producción.

Qué es quarkus-http-idempotency

Es una extensión de Quarkus que implementa el patrón Idempotency-Key de forma transparente. Su mayor virtud: una vez que está en el classpath, no requiere cambios de código. Cualquier POST o PATCH que traiga el header Idempotency-Key se maneja de forma idempotente automáticamente.

Características principales:

  • Alineada con la especificación. Sigue el borrador IETF: semántica de errores 409/422/400, huella (fingerprint) del cuerpo de la petición, política de expiración documentada y cuerpos de error en formato RFC 9457 (application/problem+json).
  • Segura para APIs multiusuario y multi-tenant. La clave de almacenamiento es un compuesto por llamante: SHA-256(principal ⊕ scope ⊕ clave-cruda). Así, un usuario nunca recibe la respuesta guardada de otro. Incluye aislamiento opcional por tenant mediante un header de scope de confianza.
  • Stores intercambiables. En memoria (Caffeine) para un solo nodo, o Redis distribuido para clústeres, detrás de una SPI sencilla.
  • Endurecida. Memoria acotada, tamaños máximos para fingerprint y cuerpo guardado, y una lista de exclusión que mantiene los headers con credenciales (Set-Cookie, Authorization, *-token…) fuera de las respuestas almacenadas.
  • Lista para reactivo. Funciona con tipos de retorno Uni/asíncronos.

Instalación

Agregá la dependencia a tu pom.xml:

<dependency>
    <groupId>io.quarkiverse.idempotency</groupId>
    <artifactId>quarkus-http-idempotency</artifactId>
    <version>${quarkus-http-idempotency.version}</version>
</dependency>

Con la extensión en el classpath, cualquier POST/PATCH que lleve un header Idempotency-Key se maneja idempotentemente. No hace falta tocar tus endpoints.

Guía de uso rápida

Veamos el comportamiento con dos llamadas idénticas:

# Primera llamada — ejecuta la operación
curl -i -H "Idempotency-Key: 8e039f93" -H "Content-Type: application/json" \
     -d '{"item":"widget"}' https://api.example.com/orders
# HTTP/1.1 201 Created · Location: /orders/order-1

# Reintento con la MISMA clave — reproduce la respuesta guardada,
# el pedido NO se crea de nuevo
curl -i -H "Idempotency-Key: 8e039f93" -H "Content-Type: application/json" \
     -d '{"item":"widget"}' https://api.example.com/orders
# HTTP/1.1 201 Created · Idempotent-Replayed: true

El segundo response llega con el header Idempotent-Replayed: true, indicando que fue una reproducción y no una nueva ejecución.

Lab: same key runs once, retry replays
Salida real del lab: la misma Idempotency-Key se ejecuta una vez; el reintento devuelve la respuesta guardada (Idempotent-Replayed: true).

Cómo se comporta en cada caso

La extensión cubre los casos límite que la lógica trivial suele ignorar:

Situación Comportamiento Estado HTTP
Clave nueva Reserva, ejecuta el handler, guarda y devuelve la respuesta el del handler
Misma clave, mismo cuerpo, completada Reproduce el estado, cuerpo y headers guardados el guardado
Misma clave, mismo cuerpo, aún en curso Rechaza — hay un reintento concurrente en progreso 409
Misma clave, cuerpo distinto Rechaza — la clave se reusó para otra petición 422
Clave requerida pero ausente/inválida Rechaza 400

El caso del 422 es clave: si un cliente reusa una clave de idempotencia para un cuerpo distinto, casi siempre es un bug del cliente. Rechazarlo evita corromper datos silenciosamente.

Spec-compliant 422 / 409 rejections
Rechazos conformes a la especificación: 422 (clave reusada con otro cuerpo) y 409 (petición aún en curso).

Seguro para multiusuario y multi-tenant

Un error común al implementar esto a mano es guardar las respuestas en una caché global por clave. El problema: si dos usuarios distintos eligen la misma clave (o un atacante adivina una), uno podría recibir la respuesta del otro —una fuga de datos grave.

quarkus-http-idempotency lo previene haciendo que la clave de almacenamiento sea un compuesto por llamante: SHA-256(principal ⊕ scope ⊕ clave-cruda). El principal es la identidad autenticada; el scope permite aislamiento adicional. Resultado: la clave de un usuario vive en un espacio separado de la de cualquier otro, y nunca se cruzan.

Para escenarios multi-tenant, podés activar aislamiento por tenant mediante un header de scope de confianza, de modo que cada inquilino tenga su propio espacio de idempotencia.

Per-tenant isolation: same key in different tenants runs separately
Aislamiento por tenant: la misma clave en tenants distintos se ejecuta por separado; solo mismo tenant + clave hace replay.

Stores: memoria local o Redis distribuido

La extensión separa la lógica de idempotencia del lugar donde se guardan las respuestas, detrás de una SPI:

  • in-memory (por defecto) — caché Caffeine acotada por max-entries. Ideal para un solo nodo o desarrollo.
  • redis — para clústeres con varios nodos. Agregá el cliente de Redis y cambiá el store:
# application.properties
quarkus.idempotency.store=redis
<dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-redis-client</artifactId>
</dependency>

El store de Redis reserva cada clave con un único round-trip atómico SET NX GET PX (requiere Redis 7.0+). Esto garantiza que, incluso con muchos nodos atendiendo reintentos en paralelo, solo uno gane la reserva y ejecute la operación.

Configuración

Todas las propiedades viven bajo el prefijo quarkus.idempotency.*: nombre del header, métodos protegidos, TTLs, backend del store, scope por tenant y límites de recursos. Un ejemplo típico:

# Activar el store distribuido
quarkus.idempotency.store=redis

# (Ejemplos ilustrativos; consultá la documentación oficial para el
#  nombre exacto y los valores por defecto de cada propiedad)
# - nombre del header de idempotencia
# - métodos HTTP protegidos (POST, PATCH)
# - tiempo de vida de las claves guardadas (TTL)
# - límites de tamaño para fingerprint y cuerpo guardado

La referencia completa de propiedades y el modelo de seguridad están en la documentación oficial.

Mejores prácticas

  • Generá la clave en el cliente, una por intención de operación. Un UUID v4 nuevo por cada acción del usuario (no por reintento). Todos los reintentos de esa acción comparten la misma clave.
  • Protegé solo los métodos que cambian estado. POST y PATCH lo necesitan; GET, HEAD y PUT idempotente por naturaleza, no.
  • Definí un TTL acorde a tu ventana de reintentos. Las claves no deben vivir para siempre: lo suficiente para cubrir reintentos realistas (minutos a horas, según el caso).
  • Usá Redis en producción con más de un nodo. La caché en memoria no se comparte entre instancias; en un clúster, dos nodos podrían ejecutar la misma operación. Redis con la reserva atómica lo evita.
  • Nunca guardes credenciales en la respuesta. La extensión ya excluye headers sensibles por defecto; no lo desactives.
  • No reuses una clave para cuerpos distintos. Si cambia la operación, cambiá la clave. El 422 está para atraparte si te equivocás.

Seguridad y robustez

La extensión está endurecida pensando en producción:

  • Memoria acotada. El store en memoria tiene un límite de entradas; no crece sin control ante una avalancha de claves.
  • Tamaños limitados. El fingerprint del cuerpo y la respuesta guardada tienen topes, evitando que un cuerpo gigante agote la memoria.
  • Lista de exclusión incondicional. Headers como Set-Cookie, Authorization y cualquiera que termine en -token nunca se almacenan en la respuesta reproducida, evitando filtrar credenciales entre reintentos.
  • Errores estándar. Los rechazos usan RFC 9457 (application/problem+json) con un Link a la documentación, fáciles de consumir por clientes.

Preguntas frecuentes

¿Tengo que cambiar mi código para usarla? No. Con la extensión en el classpath, cualquier POST/PATCH con el header Idempotency-Key se maneja solo.

¿Qué pasa si el cliente no envía el header? Depende de tu configuración: la petición puede procesarse normalmente (sin garantía de idempotencia) o rechazarse con 400 si exigís la clave.

¿Sirve para microservicios? Sí, especialmente. En arquitecturas distribuidas con reintentos automáticos entre servicios, la idempotencia es esencial. Usá el store de Redis para compartir el estado entre instancias.

¿Funciona con código reactivo? Sí, soporta tipos de retorno Uni/asíncronos.

Referencias


0 Comentarios

Deja un comentario

Marcador de posición del avatar

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.