106 lines
3.6 KiB
Python
106 lines
3.6 KiB
Python
"""Shared aiohttp session with connection pooling and exponential-backoff retry."""
|
|
import asyncio
|
|
from typing import Optional, Dict, Any
|
|
|
|
import aiohttp
|
|
|
|
from core.logger import logger
|
|
|
|
_session: Optional[aiohttp.ClientSession] = None
|
|
|
|
|
|
def _new_session() -> aiohttp.ClientSession:
|
|
connector = aiohttp.TCPConnector(limit=100, limit_per_host=20, keepalive_timeout=30)
|
|
return aiohttp.ClientSession(
|
|
connector=connector,
|
|
timeout=aiohttp.ClientTimeout(total=30),
|
|
)
|
|
|
|
|
|
async def get_session() -> aiohttp.ClientSession:
|
|
global _session
|
|
if _session is None or _session.closed:
|
|
_session = _new_session()
|
|
return _session
|
|
|
|
|
|
async def close_session():
|
|
global _session
|
|
if _session and not _session.closed:
|
|
await _session.close()
|
|
_session = None
|
|
|
|
|
|
async def api_get(url: str, **kwargs) -> Optional[Dict[str, Any]]:
|
|
return await _request("get", url, **kwargs)
|
|
|
|
|
|
async def api_post(url: str, **kwargs) -> Optional[Dict[str, Any]]:
|
|
return await _request("post", url, **kwargs)
|
|
|
|
|
|
async def api_patch(url: str, **kwargs) -> Optional[Dict[str, Any]]:
|
|
return await _request("patch", url, **kwargs)
|
|
|
|
|
|
async def _safe_text(resp) -> str:
|
|
try:
|
|
return (await resp.text())[:300]
|
|
except Exception:
|
|
return "<unreadable body>"
|
|
|
|
|
|
async def _request(method: str, url: str, **kwargs) -> Optional[Dict[str, Any]]:
|
|
"""Execute an HTTP request with up to 3 attempts and exponential backoff.
|
|
|
|
Returns the parsed body ONLY on a 2xx/3xx response. A 4xx is a client error
|
|
and returns None immediately (no retry — it won't fix itself). A 5xx is
|
|
retried like a network error, then returns None. This is the difference
|
|
between "the action succeeded" and "the server rejected it": callers gate on
|
|
a non-None result, so an error response must not look like success.
|
|
"""
|
|
max_retries = 3
|
|
backoff = 1.0
|
|
session = await get_session()
|
|
|
|
for attempt in range(max_retries):
|
|
try:
|
|
async with getattr(session, method)(url, **kwargs) as resp:
|
|
if resp.status >= 500:
|
|
body = await _safe_text(resp)
|
|
if attempt < max_retries - 1:
|
|
logger.warning(
|
|
f"{method.upper()} {url} -> {resp.status} (server error), "
|
|
f"retrying in {backoff:.0f}s: {body}"
|
|
)
|
|
await asyncio.sleep(backoff)
|
|
backoff *= 2
|
|
continue
|
|
logger.error(
|
|
f"{method.upper()} {url} -> {resp.status} after {max_retries} attempts: {body}"
|
|
)
|
|
return None
|
|
if resp.status >= 400:
|
|
logger.error(
|
|
f"{method.upper()} {url} -> {resp.status} (client error): {await _safe_text(resp)}"
|
|
)
|
|
return None
|
|
if resp.content_type == "application/json":
|
|
return await resp.json()
|
|
return {"status_code": resp.status}
|
|
except aiohttp.ClientError as exc:
|
|
if attempt < max_retries - 1:
|
|
logger.warning(
|
|
f"{method.upper()} {url} attempt {attempt + 1} failed: {exc} "
|
|
f"— retrying in {backoff:.0f}s"
|
|
)
|
|
await asyncio.sleep(backoff)
|
|
backoff *= 2
|
|
else:
|
|
logger.error(f"{method.upper()} {url} failed after {max_retries} attempts: {exc}")
|
|
except Exception as exc:
|
|
logger.error(f"{method.upper()} {url} unexpected error: {exc}")
|
|
break
|
|
|
|
return None
|