fix: A42 terminé — client httpx partagé par boucle (v7.28.0)
- `app/services/http_client.py` : `async with shared_client(timeout=15) as client:` remplace les 49 créations `async with httpx.AsyncClient(` de 14 fichiers (gitea ×21, providers oidc/oauth ×11, calendar ×4, automations ×3…) — le pool de connexions est réutilisé au lieu d'être recréé à chaque appel. __aexit__ no-op (le client partagé ne se ferme pas à la sortie). - Cache par (boucle d'event, kwargs) en WeakKeyDictionary : un AsyncClient n'est JAMAIS partagé entre deux loops (piège des tests « Event loop is closed ») — une boucle par test = client propre collecté avec la boucle. Clé = kwargs triés, repr() pour les valeurs non hashables (`headers=` dict → TypeError rattrapé par la suite). - Laissés délibérément : github_adapter (transport MockTransport injecté), webhook_outbound (client « own_client » fermé par la fonction). - Tests : `test_http_client_shared_and_loop_scoped` (réutilisation mêmes kwargs / cloisonné kwargs / cloisonné loop) ; le stub des webhooks patche aussi la fabrique `http_client.httpx` + purge du cache (avant : webhook_outbound.httpx patché mais la fabrique partagée créait un vrai client → réseau réel dans les tests). suite **1091/1091** · ruff OK · docs à jour
This commit is contained in:
@@ -309,6 +309,33 @@ def test_csp_no_cdn_and_vendor(client):
|
||||
assert len(r.content) > 500, (path, len(r.content))
|
||||
|
||||
|
||||
def test_http_client_shared_and_loop_scoped():
|
||||
"""A42 : le client HTTP partagé est réutilisé dans la même boucle,
|
||||
cloisonné par kwargs, et JAMAIS partagé entre deux boucles (un
|
||||
AsyncClient lié à une boucle morte lèverait « Event loop is closed »)."""
|
||||
import asyncio
|
||||
|
||||
from app.services.http_client import shared_client
|
||||
|
||||
async def same_loop():
|
||||
async with shared_client(timeout=15) as a:
|
||||
async with shared_client(timeout=15) as b:
|
||||
assert a is b, "même boucle + mêmes kwargs = même client"
|
||||
async with shared_client(timeout=30) as c:
|
||||
assert c is not a, "kwargs différents = client différent"
|
||||
return a
|
||||
|
||||
first = asyncio.run(same_loop())
|
||||
# nouvelle boucle (façon tests : une boucle par test) → nouveau client
|
||||
async def other_loop():
|
||||
async with shared_client(timeout=15) as d:
|
||||
assert d is not first, "client jamais réutilisé sur une boucle morte"
|
||||
return d
|
||||
|
||||
second = asyncio.run(other_loop())
|
||||
assert second is not first
|
||||
|
||||
|
||||
def test_no_duplicate_routes():
|
||||
"""A24 : deux routes même méthode+chemin → l'une écrase silencieusement l'autre."""
|
||||
from app.main import app
|
||||
|
||||
@@ -357,6 +357,12 @@ def _patch_async_client(monkeypatch, handler) -> None:
|
||||
RequestError=httpx.RequestError,
|
||||
)
|
||||
monkeypatch.setattr(webhook_outbound, "httpx", stub)
|
||||
# A42 : la fabrique partagée crée les clients — elle doit voir le stub
|
||||
# (boucle neuve par test → aucun cache à purger, on purge par sécurité).
|
||||
from app.services import http_client as _hc
|
||||
|
||||
monkeypatch.setattr(_hc, "httpx", stub)
|
||||
_hc._clients.clear()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Reference in New Issue
Block a user