fix(payments): apply request timeout changes without restart
PAYMENT_REQUEST_TIMEOUT_SECONDS was read once in each provider's __init__ and baked into the aiohttp session, so admin overrides (applied in-process) only took effect after a container restart. Providers now hand HttpClientMixin a timeout source callable; the mixin builds the session with the current value and swaps in a fresh session when the value changes, closing the replaced one only after any in-flight request on it is bound by its own total timeout. Also: - check the Heleket payment-info success flag before reading the payload so a non-dict provider response cannot raise in the pending-payment reuse path - add PAYMENT_REQUEST_TIMEOUT_SECONDS to the FreeKassa settings stub in test_security.py (fixes three tests broken by the new field)
This commit is contained in:
@@ -3,12 +3,14 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import Any, Callable, Dict, Mapping, Optional, Tuple
|
||||
from typing import Any, Callable, Dict, List, Mapping, Optional, Set, Tuple, Union
|
||||
|
||||
from aiohttp import ClientError, ClientSession, ClientTimeout, TraceConfig
|
||||
|
||||
SuccessCheck = Callable[[int, Any], bool]
|
||||
TimeoutSource = Union[float, Callable[[], float]]
|
||||
_TRANSPORT_ATTEMPTS = 2
|
||||
_DEFAULT_TIMEOUT_SECONDS = 20.0
|
||||
|
||||
|
||||
def http_ok(status: int, _body: Any) -> bool:
|
||||
@@ -115,25 +117,68 @@ class HttpClientMixin:
|
||||
``__init__`` and inherits ``_get_session`` / ``close``. The session is
|
||||
created on first use and recreated transparently if it was closed.
|
||||
|
||||
``total_timeout`` may be a callable so the timeout follows runtime
|
||||
settings changes (admin overrides apply in-process without a restart).
|
||||
When the value changes, the next request gets a fresh session; the old
|
||||
session stays open until its own in-flight requests cannot outlive it.
|
||||
|
||||
Provider API calls are traced so callers can retry transport failures only
|
||||
when aiohttp has not sent request headers yet.
|
||||
"""
|
||||
|
||||
_timeout: ClientTimeout
|
||||
_timeout_source: TimeoutSource
|
||||
_session: Optional[ClientSession]
|
||||
_stale_sessions: List[ClientSession]
|
||||
_session_cleanup_tasks: Set["asyncio.Task[None]"]
|
||||
|
||||
def _init_http_client(self, *, total_timeout: float = 20.0) -> None:
|
||||
self._timeout = ClientTimeout(total=total_timeout)
|
||||
def _init_http_client(self, *, total_timeout: TimeoutSource = _DEFAULT_TIMEOUT_SECONDS) -> None:
|
||||
self._timeout_source = total_timeout
|
||||
self._session = None
|
||||
self._stale_sessions = []
|
||||
self._session_cleanup_tasks = set()
|
||||
|
||||
def _current_timeout_seconds(self) -> float:
|
||||
source = self._timeout_source
|
||||
try:
|
||||
seconds = float(source() if callable(source) else source)
|
||||
except Exception:
|
||||
return _DEFAULT_TIMEOUT_SECONDS
|
||||
return seconds if seconds > 0 else _DEFAULT_TIMEOUT_SECONDS
|
||||
|
||||
async def _get_session(self) -> ClientSession:
|
||||
if self._session is None or self._session.closed:
|
||||
self._session = ClientSession(
|
||||
timeout=self._timeout,
|
||||
timeout_seconds = self._current_timeout_seconds()
|
||||
session = self._session
|
||||
if session is not None and not session.closed and session.timeout.total != timeout_seconds:
|
||||
self._session = None
|
||||
self._stale_sessions.append(session)
|
||||
task = asyncio.create_task(self._close_stale_session(session))
|
||||
self._session_cleanup_tasks.add(task)
|
||||
task.add_done_callback(self._session_cleanup_tasks.discard)
|
||||
session = None
|
||||
if session is None or session.closed:
|
||||
session = ClientSession(
|
||||
timeout=ClientTimeout(total=timeout_seconds),
|
||||
trace_configs=[_payment_trace_config()],
|
||||
)
|
||||
return self._session
|
||||
self._session = session
|
||||
return session
|
||||
|
||||
async def _close_stale_session(self, session: ClientSession) -> None:
|
||||
# Any request started on this session is bound by its total timeout,
|
||||
# so after that long it is safe to close without cutting one off.
|
||||
await asyncio.sleep((session.timeout.total or _DEFAULT_TIMEOUT_SECONDS) + 1.0)
|
||||
if session in self._stale_sessions:
|
||||
self._stale_sessions.remove(session)
|
||||
if not session.closed:
|
||||
await session.close()
|
||||
|
||||
async def close(self) -> None:
|
||||
if self._session and not self._session.closed:
|
||||
await self._session.close()
|
||||
for task in list(self._session_cleanup_tasks):
|
||||
task.cancel()
|
||||
self._session_cleanup_tasks.clear()
|
||||
sessions = [self._session, *self._stale_sessions]
|
||||
self._session = None
|
||||
self._stale_sessions = []
|
||||
for session in sessions:
|
||||
if session and not session.closed:
|
||||
await session.close()
|
||||
|
||||
Reference in New Issue
Block a user