Spaces:
Sleeping
No reintentar el 429 y limitar /api/interpret también por usuario
Browse filesDos 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 +16 -1
- backend/app/ai/claude.py +8 -3
- backend/app/ai/hf_space.py +23 -3
- backend/app/ai/medgemma.py +5 -1
- backend/app/ai/service.py +5 -0
- backend/app/config.py +4 -0
- backend/app/routers/interpret.py +13 -1
- backend/app/security/rate_limit.py +20 -0
- backend/tests/test_reintentos.py +91 -0
|
@@ -13,7 +13,22 @@ from ..schemas import InterpretacionClinica
|
|
| 13 |
|
| 14 |
|
| 15 |
class ErrorModelo(Exception):
|
| 16 |
-
"""Fallo
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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):
|
|
@@ -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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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)
|
|
@@ -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(
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|
|
@@ -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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
|
@@ -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:
|
|
@@ -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
|
|
@@ -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
|
|
@@ -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)}"
|
|
@@ -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
|