- `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
69 lines
2.0 KiB
Python
69 lines
2.0 KiB
Python
"""Client HTTP partagé (A42 — reliquat du audit).
|
|
|
|
51 créations `httpx.AsyncClient(...)` éparpillées dans 15 fichiers = un
|
|
nouveau pool de connexions par appel (pas de keep-alive). Ici le pool est
|
|
réutilisé, **closé par boucle d'event** : un `AsyncClient` ne traverse pas
|
|
de boucle à boucle (les tests en créent une par test → client propre par
|
|
boucle, collecté avec elle).
|
|
|
|
`async with shared_client(timeout=15) as client:` — même syntaxe que
|
|
before, l'`__aexit__` est un no-op (on ne ferme pas le client partagé).
|
|
|
|
ponytail: pas d'`aclose` explicite — le client meurt avec sa boucle
|
|
(WeakKeyDictionary, les sockets sont fermés par le GC) ; plafond : le pool
|
|
du loop produit n'est pas fermé à la main. Upgrade si besoin : lifespan
|
|
qui ferme le client du loop principal.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import weakref
|
|
|
|
import httpx
|
|
|
|
_clients: weakref.WeakKeyDictionary[asyncio.AbstractEventLoop, dict] = (
|
|
weakref.WeakKeyDictionary()
|
|
)
|
|
|
|
|
|
def _hashable(v) -> bool:
|
|
try:
|
|
hash(v)
|
|
return True
|
|
except TypeError:
|
|
return False
|
|
|
|
|
|
def _key(kwargs: dict) -> tuple:
|
|
"""Clé de cache = kwargs (timeout/auth/transport/headers…). Valeurs non
|
|
hashables (dict `headers=`…) → repr, même contrat que la clé."""
|
|
return tuple(
|
|
(k, v if _hashable(v) else repr(v))
|
|
for k, v in sorted(kwargs.items())
|
|
)
|
|
|
|
|
|
def get_shared_client(**kwargs) -> httpx.AsyncClient:
|
|
loop = asyncio.get_running_loop()
|
|
per_loop = _clients.setdefault(loop, {})
|
|
k = _key(kwargs)
|
|
client = per_loop.get(k)
|
|
if client is None:
|
|
client = httpx.AsyncClient(**kwargs)
|
|
per_loop[k] = client
|
|
return client
|
|
|
|
|
|
class shared_client:
|
|
"""Context manager async : yield du client partagé, no-op à la sortie."""
|
|
|
|
def __init__(self, **kwargs):
|
|
self._kwargs = kwargs
|
|
|
|
async def __aenter__(self) -> httpx.AsyncClient:
|
|
return get_shared_client(**self._kwargs)
|
|
|
|
async def __aexit__(self, *exc) -> bool:
|
|
return False
|