Jose Salazar Claude Opus 5 commited on
Commit
a5d73ff
·
1 Parent(s): 843a295

No reintentar el 429 y limitar /api/interpret también por usuario

Browse files

Dos cambios que atacan lo mismo: la cuota de GPU compartida es el recurso
escaso de una instancia pública gratuita, y hoy se malgasta.

1) El reintento no distinguía qué fallo estaba tratando.
service.py reintentaba ante cualquier ErrorModelo. Para la salida malformada
de medGemma (razonamiento filtrado, bucle de repetición) eso es correcto y es
su razón de ser. Para un 429 es contraproducente: gasta otra reserva de GPU
del mismo pozo agotado, justo cuando el Space pide parar. Para un rechazo por
seguridad o una clave ausente, no puede cambiar nada.

ErrorModelo pasa a llevar `reintentable` y `saturado`; cada punto de fallo se
clasifica (429 y cuota → no reintentable; 5xx → sí; config y rechazos → no).
Un 429 ahora cuesta UNA llamada en vez de dos.

/api/interpret responde 503 + Retry-After ante saturación, no un 502 genérico:
el 502 invita a recargar en bucle contra una cuota ya agotada.

2) El rate limit era sólo por IP.
ZeroGPU es por cuenta, no por Space: un usuario puede agotar la capacidad del
día para todos. La IP no sirve como identidad —cambia, y varios veterinarios
de una clínica la comparten y se penalizan entre sí—. Se añade un segundo
límite con la sesión como clave (20/hora por defecto, configurable).

Tests nuevos que cuentan LLAMADAS, que es lo que consume cuota: un 429 y un
rechazo cuestan una; la salida malformada sí se reintenta y acierta a la segunda;
el reintento no es infinito.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

backend/app/ai/base.py CHANGED
@@ -13,7 +13,22 @@ from ..schemas import InterpretacionClinica
13
 
14
 
15
  class ErrorModelo(Exception):
16
- """Fallo recuperable/no recuperable al invocar un modelo o validar su salida."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
17
 
18
 
19
  class ClienteModelo(Protocol):
 
13
 
14
 
15
  class ErrorModelo(Exception):
16
+ """Fallo al invocar un modelo o validar su salida.
17
+
18
+ `reintentable` decide si el servicio vuelve a muestrear. La distinción importa de verdad:
19
+ el reintento existe para la salida malformada de medGemma (razonamiento filtrado o bucle de
20
+ repetición), donde volver a muestrear suele funcionar. Reintentar un 429 hace lo contrario —
21
+ duplica la presión sobre la cuota de ZeroGPU, que es justo el recurso agotado— y reintentar
22
+ un rechazo por seguridad o una clave mal configurada no puede cambiar nada.
23
+
24
+ `saturado` marca los casos de límite de tasa/cuota, para poder responder 503 + Retry-After
25
+ en vez de un 502 genérico.
26
+ """
27
+
28
+ def __init__(self, mensaje: str, *, reintentable: bool = True, saturado: bool = False) -> None:
29
+ super().__init__(mensaje)
30
+ self.reintentable = reintentable
31
+ self.saturado = saturado
32
 
33
 
34
  class ClienteModelo(Protocol):
backend/app/ai/claude.py CHANGED
@@ -44,7 +44,9 @@ class ClaudeClient:
44
  def __init__(self) -> None:
45
  cfg = obtener_config()
46
  if not cfg.anthropic_api_key:
47
- raise ErrorModelo("ANTHROPIC_API_KEY no configurada para la ruta Claude.")
 
 
48
  # Import perezoso para no exigir el SDK cuando sólo se usa medGemma.
49
  from anthropic import AsyncAnthropic
50
 
@@ -75,7 +77,8 @@ class ClaudeClient:
75
  raise ErrorModelo(
76
  "El modelo rechazó la petición por sus filtros de seguridad"
77
  + (f" (categoría: {categoria})" if categoria else "")
78
- + ". Reformula el caso o usa la ruta medGemma."
 
79
  )
80
 
81
  for bloque in resp.content:
@@ -95,7 +98,9 @@ class ClaudeClient:
95
  messages=[{"role": "user", "content": mensaje}],
96
  )
97
  if resp.stop_reason == "refusal":
98
- raise ErrorModelo("El juez rechazó el caso por sus filtros de seguridad.")
 
 
99
  texto = "".join(b.text for b in resp.content if getattr(b, "type", None) == "text")
100
  try:
101
  return json.loads(texto)
 
44
  def __init__(self) -> None:
45
  cfg = obtener_config()
46
  if not cfg.anthropic_api_key:
47
+ raise ErrorModelo(
48
+ "ANTHROPIC_API_KEY no configurada para la ruta Claude.", reintentable=False
49
+ )
50
  # Import perezoso para no exigir el SDK cuando sólo se usa medGemma.
51
  from anthropic import AsyncAnthropic
52
 
 
77
  raise ErrorModelo(
78
  "El modelo rechazó la petición por sus filtros de seguridad"
79
  + (f" (categoría: {categoria})" if categoria else "")
80
+ + ". Reformula el caso o usa la ruta medGemma.",
81
+ reintentable=False,
82
  )
83
 
84
  for bloque in resp.content:
 
98
  messages=[{"role": "user", "content": mensaje}],
99
  )
100
  if resp.stop_reason == "refusal":
101
+ raise ErrorModelo(
102
+ "El juez rechazó el caso por sus filtros de seguridad.", reintentable=False
103
+ )
104
  texto = "".join(b.text for b in resp.content if getattr(b, "type", None) == "text")
105
  try:
106
  return json.loads(texto)
backend/app/ai/hf_space.py CHANGED
@@ -107,7 +107,9 @@ class HFSpaceClient:
107
  def __init__(self) -> None:
108
  cfg = obtener_config()
109
  if not cfg.hf_space_url:
110
- raise ErrorModelo("MORPHOS_HF_SPACE_URL no configurada para la ruta HF Space.")
 
 
111
  self._space = cfg.hf_space_url.rstrip("/")
112
  self._key = cfg.hf_api_key
113
 
@@ -164,8 +166,21 @@ class HFSpaceClient:
164
  )
165
  except httpx.HTTPError as exc:
166
  raise ErrorModelo(f"No se pudo contactar el HF Space: {exc}") from exc
 
 
 
 
 
 
 
 
 
167
  if r.status_code >= 400:
168
- raise ErrorModelo(f"HF Space devolvió HTTP {r.status_code}")
 
 
 
 
169
 
170
  event_id = (r.json() or {}).get("event_id")
171
  if not event_id:
@@ -180,7 +195,12 @@ class HFSpaceClient:
180
 
181
  texto, error = self._parsear_sse(stream.text)
182
  if error:
183
- raise ErrorModelo(f"HF Space: {error}")
 
 
 
 
 
184
  if texto is None:
185
  raise ErrorModelo("Sin respuesta del modelo (HF Space).")
186
 
 
107
  def __init__(self) -> None:
108
  cfg = obtener_config()
109
  if not cfg.hf_space_url:
110
+ raise ErrorModelo(
111
+ "MORPHOS_HF_SPACE_URL no configurada para la ruta HF Space.", reintentable=False
112
+ )
113
  self._space = cfg.hf_space_url.rstrip("/")
114
  self._key = cfg.hf_api_key
115
 
 
166
  )
167
  except httpx.HTTPError as exc:
168
  raise ErrorModelo(f"No se pudo contactar el HF Space: {exc}") from exc
169
+ if r.status_code == 429:
170
+ # Cuota de ZeroGPU agotada o límite de tasa del router de HF. Reintentar aquí
171
+ # sería contraproducente: gasta otra reserva de GPU del mismo pozo agotado.
172
+ raise ErrorModelo(
173
+ "El modelo está saturado (cuota de GPU agotada). Inténtalo de nuevo en unos "
174
+ "minutos, o configura la ruta local de Ollama.",
175
+ reintentable=False,
176
+ saturado=True,
177
+ )
178
  if r.status_code >= 400:
179
+ # 5xx puede ser transitorio; 4xx (auth, petición mal formada) no se arregla solo.
180
+ raise ErrorModelo(
181
+ f"HF Space devolvió HTTP {r.status_code}",
182
+ reintentable=r.status_code >= 500,
183
+ )
184
 
185
  event_id = (r.json() or {}).get("event_id")
186
  if not event_id:
 
195
 
196
  texto, error = self._parsear_sse(stream.text)
197
  if error:
198
+ # El Space suele reportar aquí la cuota de ZeroGPU agotada; eso no se arregla
199
+ # reintentando (gastaría otra reserva del mismo pozo).
200
+ sin_cuota = bool(re.search(r"quota|gpu|exceed|limit", error, re.I))
201
+ raise ErrorModelo(
202
+ f"HF Space: {error}", reintentable=not sin_cuota, saturado=sin_cuota
203
+ )
204
  if texto is None:
205
  raise ErrorModelo("Sin respuesta del modelo (HF Space).")
206
 
backend/app/ai/medgemma.py CHANGED
@@ -64,7 +64,11 @@ class MedGemmaClient:
64
  raise ErrorModelo(f"No se pudo conectar con medGemma en {self._url}: {exc}") from exc
65
 
66
  if resp.status_code >= 400:
67
- raise ErrorModelo(f"medGemma devolvió HTTP {resp.status_code}: {resp.text[:200]}")
 
 
 
 
68
 
69
  contenido = resp.json().get("message", {}).get("content", "")
70
  try:
 
64
  raise ErrorModelo(f"No se pudo conectar con medGemma en {self._url}: {exc}") from exc
65
 
66
  if resp.status_code >= 400:
67
+ raise ErrorModelo(
68
+ f"medGemma devolvió HTTP {resp.status_code}: {resp.text[:200]}",
69
+ reintentable=resp.status_code >= 500,
70
+ saturado=resp.status_code == 429,
71
+ )
72
 
73
  contenido = resp.json().get("message", {}).get("content", "")
74
  try:
backend/app/ai/service.py CHANGED
@@ -62,6 +62,11 @@ async def interpretar(pet: PeticionInterpretacion) -> RespuestaInterpretacion:
62
  break
63
  except ErrorModelo as exc:
64
  ultimo_error = exc
 
 
 
 
 
65
  log.warning("Interpretación fallida (intento %d): %s", intento + 1, exc)
66
 
67
  if resultado is None:
 
62
  break
63
  except ErrorModelo as exc:
64
  ultimo_error = exc
65
+ if not exc.reintentable:
66
+ # 429/cuota, rechazo por seguridad o configuración ausente: reintentar no puede
67
+ # ayudar y, en el caso de la cuota, gasta otra reserva de GPU del pozo agotado.
68
+ log.warning("Interpretación fallida sin reintento: %s", exc)
69
+ break
70
  log.warning("Interpretación fallida (intento %d): %s", intento + 1, exc)
71
 
72
  if resultado is None:
backend/app/config.py CHANGED
@@ -107,6 +107,10 @@ class Configuracion(BaseSettings):
107
 
108
  # --- Rate limiting ---
109
  limite_interpret: str = Field(default="10/minute")
 
 
 
 
110
  limite_login: str = Field(default="5/minute")
111
  limite_papers: str = Field(default="30/minute")
112
  limite_lab_ingesta: str = Field(default="120/minute") # el analizador puede enviar en ráfaga
 
107
 
108
  # --- Rate limiting ---
109
  limite_interpret: str = Field(default="10/minute")
110
+ # Techo por USUARIO además del de IP. La cuota de ZeroGPU es por cuenta y compartida entre
111
+ # todos los veterinarios que usan la instancia pública: sin este límite, uno solo puede
112
+ # agotar la capacidad del día. Ajustar según la cuota real del plan.
113
+ limite_interpret_usuario: str = Field(default="20/hour")
114
  limite_login: str = Field(default="5/minute")
115
  limite_papers: str = Field(default="30/minute")
116
  limite_lab_ingesta: str = Field(default="120/minute") # el analizador puede enviar en ráfaga
backend/app/routers/interpret.py CHANGED
@@ -17,7 +17,7 @@ from ..ai.service import interpretar
17
  from ..config import obtener_config
18
  from ..schemas import PeticionInterpretacion, RespuestaInterpretacion
19
  from ..security.authz import usuario_actual, verificar_csrf
20
- from ..security.rate_limit import limiter
21
 
22
  router = APIRouter()
23
 
@@ -44,6 +44,9 @@ def _validar_imagenes(imagenes: list[str]) -> None:
44
 
45
  @router.post("/interpret", response_model=RespuestaInterpretacion)
46
  @limiter.limit(obtener_config().limite_interpret)
 
 
 
47
  async def post_interpret(
48
  request: Request,
49
  pet: PeticionInterpretacion,
@@ -54,4 +57,13 @@ async def post_interpret(
54
  try:
55
  return await interpretar(pet)
56
  except ErrorModelo as exc:
 
 
 
 
 
 
 
 
 
57
  raise HTTPException(status.HTTP_502_BAD_GATEWAY, f"Error del modelo: {exc}") from exc
 
17
  from ..config import obtener_config
18
  from ..schemas import PeticionInterpretacion, RespuestaInterpretacion
19
  from ..security.authz import usuario_actual, verificar_csrf
20
+ from ..security.rate_limit import clave_usuario, limiter
21
 
22
  router = APIRouter()
23
 
 
44
 
45
  @router.post("/interpret", response_model=RespuestaInterpretacion)
46
  @limiter.limit(obtener_config().limite_interpret)
47
+ # Segundo límite, con clave por usuario: el de arriba (por IP) frena ráfagas puntuales, éste
48
+ # impide que una sola cuenta consuma la cuota de GPU compartida a lo largo del día.
49
+ @limiter.limit(obtener_config().limite_interpret_usuario, key_func=clave_usuario)
50
  async def post_interpret(
51
  request: Request,
52
  pet: PeticionInterpretacion,
 
57
  try:
58
  return await interpretar(pet)
59
  except ErrorModelo as exc:
60
+ if exc.saturado:
61
+ # 503 + Retry-After y no 502: al cliente le sirve saber que es transitorio y cuándo
62
+ # reintentar. Un 502 genérico invita a recargar en bucle, que es lo peor que puede
63
+ # hacerse contra una cuota agotada.
64
+ raise HTTPException(
65
+ status.HTTP_503_SERVICE_UNAVAILABLE,
66
+ str(exc),
67
+ headers={"Retry-After": "300"},
68
+ ) from exc
69
  raise HTTPException(status.HTTP_502_BAD_GATEWAY, f"Error del modelo: {exc}") from exc
backend/app/security/rate_limit.py CHANGED
@@ -7,7 +7,27 @@ Los límites concretos son configurables en config.py.
7
 
8
  from __future__ import annotations
9
 
 
10
  from slowapi import Limiter
11
  from slowapi.util import get_remote_address
12
 
 
 
13
  limiter = Limiter(key_func=get_remote_address)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
 
8
  from __future__ import annotations
9
 
10
+ from fastapi import Request
11
  from slowapi import Limiter
12
  from slowapi.util import get_remote_address
13
 
14
+ from .session import COOKIE_SESION, leer_sesion
15
+
16
  limiter = Limiter(key_func=get_remote_address)
17
+
18
+
19
+ def clave_usuario(request: Request) -> str:
20
+ """Clave de rate limiting por USUARIO, no por IP.
21
+
22
+ En una herramienta gratuita el recurso escaso es la cuota de GPU compartida (ZeroGPU es por
23
+ cuenta, no por Space): un solo usuario puede agotarla para todos los demás. El límite por IP
24
+ no lo evita —una IP doméstica cambia, y varios veterinarios de una misma clínica comparten
25
+ IP, penalizándose entre sí—. Con la sesión como clave, el coste se imputa a quien lo genera.
26
+
27
+ Sin sesión se cae a la IP: /api/interpret exige sesión, así que ese caso sólo aparece si
28
+ cambia la guarda de auth.
29
+ """
30
+ sesion = leer_sesion(request.cookies.get(COOKIE_SESION))
31
+ if sesion and sesion.get("email"):
32
+ return f"user:{sesion['email']}"
33
+ return f"ip:{get_remote_address(request)}"
backend/tests/test_reintentos.py ADDED
@@ -0,0 +1,91 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Política de reintentos del servicio de interpretación.
2
+
3
+ El reintento existe para una sola cosa: la salida malformada de medGemma (razonamiento filtrado
4
+ o bucle de repetición), donde volver a muestrear suele arreglarlo. Reintentar un 429 hace lo
5
+ contrario —gasta otra reserva de GPU del mismo pozo agotado— y reintentar un rechazo por
6
+ seguridad o una clave ausente no puede cambiar el resultado.
7
+
8
+ Estos tests cuentan LLAMADAS, que es lo que consume cuota.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import pytest
14
+
15
+ from app.ai import service
16
+ from app.ai.base import ErrorModelo
17
+ from app.schemas import InterpretacionClinica, PeticionInterpretacion
18
+
19
+ PETICION = {
20
+ "paciente": {"especie": "canino"},
21
+ "hallazgos": [
22
+ {
23
+ "clave": "hct",
24
+ "nombre": "Hematocrito",
25
+ "valor": 22.0,
26
+ "unidad": "%",
27
+ "direccion": "bajo",
28
+ "gravedad": "grave",
29
+ }
30
+ ],
31
+ "patrones": [{"nombre": "Anemia", "descripcion": "…", "gravedad": "grave"}],
32
+ "imagenes": [],
33
+ }
34
+
35
+
36
+ class ClienteFalso:
37
+ nombre = "medgemma-hf"
38
+
39
+ def __init__(self, error: ErrorModelo | None, exito_en: int | None = None):
40
+ self.error = error
41
+ self.exito_en = exito_en
42
+ self.llamadas = 0
43
+
44
+ async def interpretar(self, *_a, **_k):
45
+ self.llamadas += 1
46
+ if self.exito_en is not None and self.llamadas >= self.exito_en:
47
+ return InterpretacionClinica(interpretacion="ok " * 20, requiere_derivacion=True)
48
+ raise self.error
49
+
50
+
51
+ @pytest.fixture
52
+ def sin_rag(monkeypatch):
53
+ """Aísla del retriever: estos tests miden reintentos, no recuperación."""
54
+ monkeypatch.setattr(service, "recuperar", lambda *_a, **_k: [])
55
+
56
+
57
+ async def _interpretar_con(cliente, monkeypatch):
58
+ monkeypatch.setattr(service, "_crear_cliente", lambda _b: cliente)
59
+ return await service.interpretar(PeticionInterpretacion.model_validate(PETICION))
60
+
61
+
62
+ async def test_saturado_no_se_reintenta(sin_rag, monkeypatch):
63
+ """El caso que motiva todo: un 429 debe costar UNA llamada, no dos."""
64
+ err = ErrorModelo("cuota agotada", reintentable=False, saturado=True)
65
+ cliente = ClienteFalso(err)
66
+ with pytest.raises(ErrorModelo) as exc:
67
+ await _interpretar_con(cliente, monkeypatch)
68
+ assert cliente.llamadas == 1, "un 429 reintentado duplica el gasto de cuota"
69
+ assert exc.value.saturado is True
70
+
71
+
72
+ async def test_rechazo_de_seguridad_no_se_reintenta(sin_rag, monkeypatch):
73
+ cliente = ClienteFalso(ErrorModelo("rechazo", reintentable=False))
74
+ with pytest.raises(ErrorModelo):
75
+ await _interpretar_con(cliente, monkeypatch)
76
+ assert cliente.llamadas == 1
77
+
78
+
79
+ async def test_salida_malformada_si_se_reintenta(sin_rag, monkeypatch):
80
+ """La razón de ser del reintento: se vuelve a muestrear y la segunda sale bien."""
81
+ cliente = ClienteFalso(ErrorModelo("razonamiento filtrado"), exito_en=2)
82
+ resultado = await _interpretar_con(cliente, monkeypatch)
83
+ assert cliente.llamadas == 2
84
+ assert resultado.resultado.interpretacion.startswith("ok")
85
+
86
+
87
+ async def test_el_reintento_no_es_infinito(sin_rag, monkeypatch):
88
+ cliente = ClienteFalso(ErrorModelo("siempre malformada"))
89
+ with pytest.raises(ErrorModelo):
90
+ await _interpretar_con(cliente, monkeypatch)
91
+ assert cliente.llamadas == 2