import httpx from typing import AsyncGenerator, Dict, Any, Optional from fastapi import Request, Response from fastapi.responses import StreamingResponse import logging logger = logging.getLogger("hermes_hub.proxy") HOP_BY_HOP_HEADERS = { "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade", "content-length" } # Global async client for connection pooling _http_client: Optional[httpx.AsyncClient] = None def get_http_client() -> httpx.AsyncClient: global _http_client if _http_client is None or _http_client.is_closed: _http_client = httpx.AsyncClient( timeout=httpx.Timeout(connect=5.0, read=120.0, write=60.0, pool=30.0), follow_redirects=True, limits=httpx.Limits(max_keepalive_connections=50, max_connections=100) ) return _http_client async def close_http_client(): global _http_client if _http_client and not _http_client.is_closed: await _http_client.aclose() _http_client = None async def check_backend_health(backend_url: str) -> Dict[str, Any]: client = get_http_client() try: # Test root or /health res = await client.get(backend_url, timeout=3.0) return { "status": "online" if res.status_code < 500 else "degraded", "status_code": res.status_code, "latency_ms": int(res.elapsed.total_seconds() * 1000) } except Exception as e: return { "status": "offline", "error": str(e), "latency_ms": None } async def proxy_request( request: Request, backend_url: str, path: str ) -> Response: client = get_http_client() # Strip trailing slash from backend_url and leading from path base_url = backend_url.rstrip("/") sub_path = path.lstrip("/") target_url = f"{base_url}/{sub_path}" if sub_path else base_url if request.url.query: target_url = f"{target_url}?{request.url.query}" # Filter incoming request headers req_headers = {} for key, value in request.headers.items(): if key.lower() not in HOP_BY_HOP_HEADERS and key.lower() != "host": req_headers[key] = value body = await request.body() try: upstream_req = client.build_request( method=request.method, url=target_url, headers=req_headers, content=body ) upstream_res = await client.send(upstream_req, stream=True) # Filter response headers res_headers = {} for key, value in upstream_res.headers.items(): if key.lower() not in HOP_BY_HOP_HEADERS: res_headers[key] = value async def stream_content() -> AsyncGenerator[bytes, None]: try: async for chunk in upstream_res.aiter_raw(): yield chunk finally: await upstream_res.aclose() return StreamingResponse( stream_content(), status_code=upstream_res.status_code, headers=res_headers, media_type=upstream_res.headers.get("content-type") ) except httpx.ConnectError: logger.error(f"Failed to connect to backend at {target_url}") return Response( content=f"
Le backend sur {backend_url} ne répond pas. Vérifiez le tunnel Tailscale ou l'état du conteneur.