Files
flowdeck/app/services/http_client.py
T
bruno 3bb8e87ef2
FlowDeck CI / lint (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 0s
FlowDeck CI / docker (push) Canceled after 0s
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
2026-10-01 23:09:45 -04:00

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