fix: CI verte — fuite du client HTTP mocké + rate limiter actif dans test_agent
FlowDeck CI / lint (push) Successful in 1m40s
FlowDeck CI / test (push) Successful in 24m45s
FlowDeck CI / docker (push) Successful in 2m8s

Deux causes indépendantes faisaient échouer le job test de la CI (et
seulement en CI : ni .env ni les mêmes ordres de chargement en local) :

- test_agent_web_tools.mock_http patche http_client.shared_client pendant
  sa fenêtre d'exécution. Le premier import d'importers/url_fetch dans
  cette fenêtre fige la factory moquée dans le namespace du module →
  tous les appels fetch_url suivants du processus passaient par le handler
  mocké de l'autre test (« assert '…/post' == '…/page' » dans
  test_v56_import). url_fetch résout désormais le client à l'appel, et le
  mock_http restaure la vraie factory (référence figée au chargement du
  module) y compris sur url_fetch ;
- test_agent.py posait RATE_LIMIT_ENABLED=false en env var, sans effet sur
  le singleton Settings déjà instancié : sous pytest -n auto, le worker
  dépassait le quota de 60 req/min et 8 tests recevaient des 429
  (KeyError 'id' au passage). Le fixture désactive maintenant le limiter
  sur le singleton, comme les autres fichiers de tests.
This commit is contained in:
2026-10-09 17:55:44 -04:00
parent 0218d8f5e5
commit 9ea0cddf3c
3 changed files with 24 additions and 3 deletions
+5 -2
View File
@@ -11,8 +11,8 @@ from urllib.parse import urlparse
import httpx import httpx
from app.services import http_client
from app.services.export import markdown_to_blocks from app.services.export import markdown_to_blocks
from app.services.http_client import shared_client
from app.services.importers.base import ImportPage, ImportResult from app.services.importers.base import ImportPage, ImportResult
from app.services.importers.html_notes import _html_to_markdown from app.services.importers.html_notes import _html_to_markdown
@@ -65,7 +65,10 @@ async def fetch_url_result(url: str, *, transport: httpx.BaseTransport | None =
safe_url = _validate_url(url) safe_url = _validate_url(url)
result = ImportResult(source="url") result = ImportResult(source="url")
try: try:
async with shared_client( # Résolu à l'APPEL (et non capturé à l'import) : un import tardif de
# ce module pendant qu'un test patche `http_client.shared_client`
# figerait la factory moquée pour tout le reste du processus.
async with http_client.shared_client(
timeout=15, follow_redirects=True, transport=transport, timeout=15, follow_redirects=True, transport=transport,
headers={"User-Agent": "FlowDeck-Importer/1.0"}, headers={"User-Agent": "FlowDeck-Importer/1.0"},
) as client: ) as client:
+6
View File
@@ -36,6 +36,12 @@ def client():
settings.agent_max_iterations = 12 settings.agent_max_iterations = 12
settings.agent_max_tokens_budget = 500_000 settings.agent_max_tokens_budget = 500_000
settings.agent_run_timeout_seconds = 30 settings.agent_run_timeout_seconds = 30
# Le rate limiter est DÉJÀ instancié (singleton importé avant ce fixture) :
# `os.environ["RATE_LIMIT_ENABLED"]="false"]` ci-dessus ne le touche pas.
# Sans cette mutation, les tests suivants d'un même worker dépassent le
# quota (60 req/min) et reçoivent des 429 — visible sous `pytest -n auto`
# en CI, invisible en local (.env de dev désactivé le limiter).
settings.rate_limit_enabled = False
init_db() init_db()
with get_conn() as conn: with get_conn() as conn:
+13 -1
View File
@@ -70,6 +70,13 @@ def registry() -> ToolRegistry:
return ToolRegistry() return ToolRegistry()
#: Vraie factory du client partagé, figée AU CHARGEMENT du module (donc avant
#: tout patch). La restauration repart de CETTE référence : si un import tardif
#: d'un module consommateur a déjà figé une factory moquée, le `finally` la
#: répare au lieu de perpétuer la fuite.
_REAL_SHARED_CLIENT = http_client.shared_client
@contextlib.contextmanager @contextlib.contextmanager
def mock_http(handler): def mock_http(handler):
"""Injecte un transport httpx dans le client partagé des services. """Injecte un transport httpx dans le client partagé des services.
@@ -95,7 +102,12 @@ def mock_http(handler):
finally: finally:
for module in targets: for module in targets:
if getattr(module, "shared_client", None) is build: if getattr(module, "shared_client", None) is build:
module.shared_client = original module.shared_client = _REAL_SHARED_CLIENT
# Un consommateur importé PENDANT la fenêtre patchée a figé `build`
# dans son propre namespace : on le remet sur la vraie factory.
from app.services.importers import url_fetch
if getattr(url_fetch, "shared_client", None) is build:
url_fetch.shared_client = _REAL_SHARED_CLIENT
def run(coro): def run(coro):