Merge GitHub dev into GitLab dev

This commit is contained in:
BADtochka
2026-06-09 13:21:26 +03:00
22 changed files with 369 additions and 109 deletions
+1 -1
View File
@@ -153,7 +153,7 @@ class FreeKassaService(HttpClientMixin):
self.default_currency: str = default_payment_currency_code_for_settings(settings).upper()
self.api_base_url: str = "https://api.fk.life/v1"
self._init_http_client(total_timeout=15)
self._init_http_client(total_timeout=self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
self._nonce_lock = asyncio.Lock()
self._last_nonce = int(time.time() * 1000)
+1 -1
View File
@@ -243,7 +243,7 @@ class HeleketService(HttpClientMixin):
self.referral_service = referral_service
self._default_return_url = default_return_url
self._init_http_client(total_timeout=20)
self._init_http_client(total_timeout=self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
if not self.configured:
logging.warning(
"HeleketService initialized but not fully configured. Payments disabled."
+1 -1
View File
@@ -546,7 +546,7 @@ class PaykillaService(HttpClientMixin):
self._exchange_rate_cache: Dict[tuple[str, str], tuple[float, Decimal]] = {}
self._currency_cache: tuple[float, List[Dict[str, Any]]] = (0, [])
self._init_http_client(total_timeout=20)
self._init_http_client(total_timeout=self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
if not self.configured:
logging.warning(
"PaykillaService initialized but not fully configured. Payments disabled."
+1 -1
View File
@@ -157,7 +157,7 @@ class PlategaService(HttpClientMixin):
self.referral_service = referral_service
self._default_return_url = default_return_url
self._init_http_client(total_timeout=20)
self._init_http_client(total_timeout=self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
if not self.configured:
logging.warning(
"PlategaService initialized but not fully configured. Payments disabled."
+1 -1
View File
@@ -136,7 +136,7 @@ class SeverPayService(HttpClientMixin):
self.referral_service = referral_service
self._default_return_url = default_return_url
self._init_http_client(total_timeout=15)
self._init_http_client(total_timeout=self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
if not self.configured:
logging.warning(
@@ -1,12 +1,14 @@
from __future__ import annotations
import asyncio
import json
import logging
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
from aiohttp import ClientSession, ClientTimeout
from aiohttp import ClientError, ClientSession, ClientTimeout, TraceConfig
SuccessCheck = Callable[[int, Any], bool]
_TRANSPORT_ATTEMPTS = 2
def http_ok(status: int, _body: Any) -> bool:
@@ -14,6 +16,29 @@ def http_ok(status: int, _body: Any) -> bool:
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,
@@ -29,34 +54,47 @@ async def post_json_request(
returns ``(False, {"status": ..., "message": ..., "raw": ...?})`` so callers
can decide what to do (typically: mark the payment as ``failed_creation``).
"""
try:
async with session.post(
url,
json=body,
headers=dict(headers) if headers else None,
) 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)",
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,
response.status,
response_data,
attempt + 1,
_TRANSPORT_ATTEMPTS,
exc,
)
return False, {"status": response.status, "message": response_data}
return True, response_data
except Exception as exc:
logging.exception("%s: request failed.", log_prefix)
return False, {"message": str(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]:
@@ -76,6 +114,9 @@ class HttpClientMixin:
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
@@ -87,7 +128,10 @@ class HttpClientMixin:
async def _get_session(self) -> ClientSession:
if self._session is None or self._session.closed:
self._session = ClientSession(timeout=self._timeout)
self._session = ClientSession(
timeout=self._timeout,
trace_configs=[_payment_trace_config()],
)
return self._session
async def close(self) -> None:
+1 -1
View File
@@ -206,7 +206,7 @@ class WataService(HttpClientMixin):
self._default_return_url = default_return_url
self._cached_public_key_pem = None # populated by webhook on first verify
self._init_http_client(total_timeout=10)
self._init_http_client(total_timeout=self.settings.PAYMENT_REQUEST_TIMEOUT_SECONDS)
if not self.configured:
logging.warning("WataService initialized but not fully configured. Payments disabled.")