from __future__ import annotations import asyncio import json import logging from typing import Any, Callable, Dict, Mapping, Optional, Tuple from aiohttp import ClientError, ClientSession, ClientTimeout, TraceConfig SuccessCheck = Callable[[int, Any], bool] _TRANSPORT_ATTEMPTS = 2 def http_ok(status: int, _body: Any) -> bool: """Default success criterion — HTTP 200 with any body.""" return status == 200 def _trace_request_ctx(trace_config_ctx: Any) -> Optional[dict]: ctx = getattr(trace_config_ctx, "trace_request_ctx", None) return ctx if isinstance(ctx, dict) else None async def _mark_request_headers_sent(session, trace_config_ctx, params) -> None: ctx = _trace_request_ctx(trace_config_ctx) if ctx is not None: ctx["headers_sent"] = True def _payment_trace_config() -> TraceConfig: trace_config = TraceConfig() trace_config.on_request_headers_sent.append(_mark_request_headers_sent) return trace_config def _should_retry_transport_error(exc: Exception, trace_ctx: Mapping[str, Any]) -> bool: if trace_ctx.get("headers_sent"): return False return isinstance(exc, (asyncio.TimeoutError, ClientError, OSError)) async def post_json_request( session: ClientSession, url: str, *, body: Any, headers: Optional[Mapping[str, str]] = None, log_prefix: str, is_success: SuccessCheck = http_ok, ) -> Tuple[bool, Dict[str, Any]]: """Centralized JSON-POST every HTTP-API provider used to inline ~25 lines for. On transport failure, JSON decode failure, or rejected ``is_success`` check, returns ``(False, {"status": ..., "message": ..., "raw": ...?})`` so callers can decide what to do (typically: mark the payment as ``failed_creation``). """ for attempt in range(1, _TRANSPORT_ATTEMPTS + 1): trace_ctx: dict[str, Any] = {"headers_sent": False} try: async with session.post( url, json=body, headers=dict(headers) if headers else None, trace_request_ctx=trace_ctx, ) as response: response_text = await response.text() try: response_data = json.loads(response_text) if response_text else {} except json.JSONDecodeError: logging.error("%s: invalid JSON response: %s", log_prefix, response_text) return False, { "status": response.status, "message": "invalid_json", "raw": response_text, } if not is_success(response.status, response_data): logging.error( "%s: API returned error (status=%s, body=%s)", log_prefix, response.status, response_data, ) return False, {"status": response.status, "message": response_data} return True, response_data except Exception as exc: if attempt < _TRANSPORT_ATTEMPTS and _should_retry_transport_error(exc, trace_ctx): logging.warning( "%s: transport failed before request headers were sent; retrying (%s/%s): %s", # noqa: E501 log_prefix, attempt + 1, _TRANSPORT_ATTEMPTS, exc, ) continue logging.exception("%s: request failed.", log_prefix) return False, {"message": str(exc)} return False, {"message": "request_failed"} def first_value(data: Optional[Mapping[str, Any]], *keys: str) -> Optional[str]: """Return the first non-empty value among ``keys`` (cast to ``str``).""" if not data: return None for key in keys: value = data.get(key) if value: return str(value) return None class HttpClientMixin: """Shared lazy ``aiohttp.ClientSession`` lifecycle for provider services. Each subclass calls ``self._init_http_client(total_timeout=...)`` from ``__init__`` and inherits ``_get_session`` / ``close``. The session is created on first use and recreated transparently if it was closed. Provider API calls are traced so callers can retry transport failures only when aiohttp has not sent request headers yet. """ _timeout: ClientTimeout _session: Optional[ClientSession] def _init_http_client(self, *, total_timeout: float = 20.0) -> None: self._timeout = ClientTimeout(total=total_timeout) self._session = None async def _get_session(self) -> ClientSession: if self._session is None or self._session.closed: self._session = ClientSession( timeout=self._timeout, trace_configs=[_payment_trace_config()], ) return self._session async def close(self) -> None: if self._session and not self._session.closed: await self._session.close()