Some checks failed
E2E Tests / e2e (push) Waiting to run
Tests unitaires / Web (Angular · vitest + couverture) (push) Successful in 26s
Build & Push Images / tests (push) Failing after 13s
Build & Push Images / build (brain) (push) Has been skipped
Build & Push Images / build (core) (push) Has been skipped
Build & Push Images / build (web) (push) Has been skipped
Build & Push Images / build-switcher (push) Has been skipped
Tests unitaires / Brain (Python · pytest + couverture) (push) Successful in 1m12s
Tests unitaires / Core (Java · mvn test + JaCoCo) (push) Failing after 1m28s
Mise en place de tests unitaires coté Python et Angular Mise en place de la couverture de test directement dans le workflow : le programme ne build pas si jamais un test échoue Passage en v0.16.2 en conséquence
113 lines
3.7 KiB
Python
113 lines
3.7 KiB
Python
"""Tests du retry des appels LLM one-shot (app.application.llm_retry).
|
|
|
|
`asyncio.sleep` est neutralisé (et enregistré) pour que les backoffs n'imposent
|
|
aucune attente réelle tout en vérifiant les durées choisies.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.application import llm_retry
|
|
from app.application.llm_retry import (
|
|
_is_daily_quota,
|
|
_is_rate_limit,
|
|
_suggested_retry_after,
|
|
generate_with_retry,
|
|
)
|
|
from app.domain.ports import LLMGenerationTimeout, LLMProviderError
|
|
|
|
|
|
class FakeLLM:
|
|
"""LLM factice : rejoue une liste de comportements (exception ou texte)."""
|
|
|
|
def __init__(self, behaviors: list) -> None:
|
|
self._behaviors = list(behaviors)
|
|
self.calls = 0
|
|
|
|
async def generate(self, prompt: str, *, output_format=None, temperature=None) -> str:
|
|
b = self._behaviors[self.calls]
|
|
self.calls += 1
|
|
if isinstance(b, Exception):
|
|
raise b
|
|
return b
|
|
|
|
|
|
@pytest.fixture
|
|
def slept(monkeypatch):
|
|
"""Neutralise asyncio.sleep et enregistre les durées demandées."""
|
|
recorded: list[float] = []
|
|
|
|
async def fake_sleep(d):
|
|
recorded.append(d)
|
|
|
|
monkeypatch.setattr("asyncio.sleep", fake_sleep)
|
|
return recorded
|
|
|
|
|
|
# --- helpers de classification -------------------------------------------------
|
|
|
|
def test_is_rate_limit():
|
|
assert _is_rate_limit(LLMProviderError("HTTP 429 Too Many Requests"))
|
|
assert _is_rate_limit(LLMProviderError("rate limit reached"))
|
|
assert not _is_rate_limit(LLMProviderError("HTTP 500"))
|
|
|
|
|
|
def test_is_daily_quota():
|
|
assert _is_daily_quota(LLMProviderError("free-models-per-day limit"))
|
|
assert _is_daily_quota(LLMProviderError("quota per day exceeded"))
|
|
assert not _is_daily_quota(LLMProviderError("429 per-minute"))
|
|
|
|
|
|
def test_suggested_retry_after():
|
|
assert _suggested_retry_after(LLMProviderError('{"retry_after_seconds": 8}')) == 8.0
|
|
assert _suggested_retry_after(LLMProviderError('Retry-After: 12')) == 12.0
|
|
assert _suggested_retry_after(LLMProviderError("pas de hint")) is None
|
|
|
|
|
|
# --- generate_with_retry -------------------------------------------------------
|
|
|
|
async def test_returns_on_first_success(slept):
|
|
llm = FakeLLM(["réponse"])
|
|
assert await generate_with_retry(llm, "p") == "réponse"
|
|
assert llm.calls == 1
|
|
assert slept == []
|
|
|
|
|
|
async def test_retries_transient_error_then_succeeds(slept):
|
|
llm = FakeLLM([LLMProviderError("HTTP 503"), "ok"])
|
|
assert await generate_with_retry(llm, "p") == "ok"
|
|
assert llm.calls == 2
|
|
assert slept == [3.0] # _BASE_DELAY_SECONDS
|
|
|
|
|
|
async def test_timeout_raises_immediately_without_retry(slept):
|
|
llm = FakeLLM([LLMGenerationTimeout("trop lent")])
|
|
with pytest.raises(LLMGenerationTimeout):
|
|
await generate_with_retry(llm, "p")
|
|
assert llm.calls == 1
|
|
assert slept == []
|
|
|
|
|
|
async def test_daily_quota_aborts_immediately(slept):
|
|
llm = FakeLLM([LLMProviderError("free-models-per-day exceeded")])
|
|
with pytest.raises(LLMProviderError):
|
|
await generate_with_retry(llm, "p")
|
|
assert llm.calls == 1
|
|
assert slept == []
|
|
|
|
|
|
async def test_exhausts_attempts_then_raises_last(slept):
|
|
llm = FakeLLM([LLMProviderError("503 a"), LLMProviderError("503 b"), LLMProviderError("503 c")])
|
|
with pytest.raises(LLMProviderError, match="503 c"):
|
|
await generate_with_retry(llm, "p")
|
|
assert llm.calls == 3
|
|
# 2 attentes entre 3 tentatives (backoff exponentiel 3s puis 6s).
|
|
assert slept == [3.0, 6.0]
|
|
|
|
|
|
async def test_rate_limit_respects_suggested_retry_after(slept):
|
|
llm = FakeLLM([LLMProviderError('429 {"retry_after_seconds": 8}'), "ok"])
|
|
assert await generate_with_retry(llm, "p") == "ok"
|
|
# min(8 + 2, 60) = 10
|
|
assert slept == [10.0]
|