Skip to content

Ola 2 · Sebas 2.11: Idempotency-Key y límite de tasa por actor - #15

Open
heysebitas wants to merge 1 commit into
feat/o1-1.3-sesion-actor-realfrom
feat/o2-2.11-idempotencia
Open

heysebitas wants to merge 1 commit into
feat/o1-1.3-sesion-actor-realfrom
feat/o2-2.11-idempotencia

Conversation

@heysebitas

Copy link
Copy Markdown
Collaborator

Tarea 2.11 · va sobre #14.

El problema

Spec §0: "Reintentos por mala conectividad de la ambulancia son la norma, no la excepción." La idempotencia existía solo dentro de RoutingStore: un POST /dispatch reintentado creaba dos handshakes.

Qué trae

  • Interceptor global de Idempotency-Key. Clave = la acción (la manda el cliente), huella = método + ruta + cuerpo canónico (la calcula el servidor). Misma clave y misma huella → resultado guardado con Idempotency-Replayed: true. Misma clave con cuerpo distinto409, no un 200 con la respuesta vieja: eso sería contestar a una pregunta que nadie hizo.
  • Un reintento que llega mientras corre la primera espera su resultado en vez de recibir un error. Es el doble toque del paramédico con mala señal.
  • Un fallo NO se cachea. Guardar un 500 convertiría un timeout de Mapbox en un error permanente para esa clave.
  • Sin cabecera, todo sigue igual. No se deriva la clave del cuerpo: dos pacientes con el mismo cuadro en la misma esquina son dos emergencias, y colisionarlas borra la segunda en silencio.
  • /auth/* exento — su efecto viaja en la cookie, no en el cuerpo; repetir el cuerpo daría un 200 con una sesión que no existe, y cachear /auth/refresh desharía la detección de reuso de 1.3.
  • Tabla idempotencia (migración 0005) generalizando pulso_routing_idempotency, con purga a 24 h. Sin URL, memoria — y el log dice que un reintento en otra instancia no se reconocería.
  • Límite de tasa por actor Y por organización, con Retry-After y retryable: true. Sin el eje de organización, una IPS con 200 actores tumba el sistema sin que ninguno pase su propio límite.
  • PulsoError admite estado HTTP (409, 429). Antes todo era 400 y la cola offline no podía distinguir "no insistas" de "espera y vuelve".
  • El front manda claves derivadas de la acción en dispatch, respond y escalar; /campo genera una por dictado y la reusa al reintentar — no un randomUUID() por intento, que es justo el bug que esto cierra.

La trampa que no pisé

/triage tiene su propio cubo y nunca hace esperar más de 5 s. Un paramédico con un paciente crítico reintentando no es un abusador: es el usuario haciendo lo que el sistema le pide en el peor momento de su turno, y bloquearlo es el peor fallo posible de PULSO — peor que caerse, porque caerse se ve.

Hecho cuando

  • La misma mutación con la misma clave dos veces → un solo efecto
  • La misma clave con cuerpo distinto → 409
  • El rate limit devuelve Retry-After y no rompe la UI
  • /campo reintenta sin duplicar

16 tests nuevos. Verificado end-to-end sobre HTTP: reintento que replica, 409 por cuerpo distinto, y ráfaga simultánea con la misma clave que produce un solo efecto (201 + 201 con Idempotency-Replayed: true en la segunda).

⚠️ Ojo al revisar: hay dos guards globales nuevos. Si una ruta empieza a devolver 429 sin razón aparente, es LimiteTasaGuard.

🤖 Generated with Claude Code

Spec §0: "Reintentos por mala conectividad de la ambulancia son la norma, no
la excepcion." La idempotencia existia SOLO dentro de RoutingStore: un
POST /dispatch reintentado creaba dos handshakes.

  - Interceptor global de `Idempotency-Key`: clave (la accion) + huella
    (metodo+ruta+cuerpo canonico). Misma clave y misma huella devuelve el
    resultado guardado con `Idempotency-Replayed: true`; misma clave con
    cuerpo distinto es 409, no un 200 con la respuesta vieja.
  - Un reintento que llega MIENTRAS corre la primera espera su resultado en
    vez de recibir un error: es el doble toque del paramedico con mala señal.
  - Un fallo NO se cachea. Guardar un 500 convierte un timeout de Mapbox en
    un error permanente para esa clave.
  - Sin cabecera, todo sigue igual: no se deriva la clave del cuerpo porque
    dos pacientes con el mismo cuadro en la misma esquina son dos
    emergencias, y colisionarlas borra la segunda en silencio.
  - `/auth/*` queda exento: su efecto viaja en la cookie, no en el cuerpo, y
    repetir el cuerpo daria un 200 con una sesion que no existe.
  - Tabla `idempotencia` (migracion 0005) generalizando
    `pulso_routing_idempotency`, con purga a 24 h. Sin URL, memoria — y se
    dice en el log que un reintento en otra instancia no se reconoceria.
  - Limite de tasa por actor Y por organizacion, con Retry-After y
    `retryable: true`. **`/triage` tiene su propio cubo y nunca hace esperar
    mas de 5 s**: un paramedico con un paciente critico reintentando no es un
    abusador, y bloquearlo es el peor fallo posible del sistema.
  - `PulsoError` admite estado HTTP (409, 429). Antes todo era 400 y la cola
    offline no podia distinguir "no insistas" de "espera y vuelve".
  - El front manda claves derivadas de la accion en dispatch, respond y
    escalar; `/campo` genera una por dictado y la reusa al reintentar.

Verificado end-to-end sobre HTTP: reintento que replica, 409 por cuerpo
distinto, y rafaga simultanea con la misma clave que produce un solo efecto.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant