Files
remnawave-bedolaga-telegram…/app/lib/nalogo/_http.py
gy9vin a362ef9f25 refactor(nalogo): восстановить описание чеков из настроек и использовать локальную библиотеку
- Добавлено восстановление описания чека из настроек при обработке очереди
- Передача telegram_user_id и amount_kopeks через всю цепочку создания чеков
- Переход на локальную исправленную версию библ
2025-12-28 04:58:05 +03:00

189 lines
5.8 KiB
Python

"""
Internal HTTP client and authentication middleware.
Based on PHP library's AuthenticationPlugin and HTTP architecture.
"""
import asyncio
from abc import ABC, abstractmethod
from http import HTTPStatus
from typing import Any
import httpx
from .exceptions import raise_for_status
class AuthProvider(ABC):
"""Abstract interface for authentication provider."""
@abstractmethod
async def get_token(self) -> dict[str, Any] | None:
"""Get current access token data."""
@abstractmethod
async def refresh(self, refresh_token: str) -> dict[str, Any] | None:
"""Refresh access token using refresh token."""
class AsyncHTTPClient:
"""
Async HTTP client with automatic token refresh on 401 responses.
Based on PHP's AuthenticationPlugin behavior:
- Adds Bearer authorization header
- On 401 response, attempts token refresh once
- Retries request with new token (max 2 attempts)
"""
def __init__(
self,
base_url: str,
auth_provider: AuthProvider,
default_headers: dict[str, str] | None = None,
timeout: float = 10.0,
):
self.base_url = base_url
self.auth_provider = auth_provider
self.default_headers = default_headers or {}
self.timeout = timeout
self._refresh_lock = asyncio.Lock()
self.max_retries = 2 # Same as PHP AuthenticationPlugin::RETRY_LIMIT
async def _get_auth_headers(self) -> dict[str, str]:
"""Get authorization headers from current token."""
token_data = await self.auth_provider.get_token()
if not token_data or "token" not in token_data:
return {}
return {"Authorization": f"Bearer {token_data['token']}"}
async def _handle_401_response(
self, client: httpx.AsyncClient, request: httpx.Request
) -> httpx.Response | None:
"""
Handle 401 response by refreshing token and retrying request.
Uses asyncio.Lock to prevent concurrent refresh attempts,
similar to PHP's retry storage mechanism.
"""
async with self._refresh_lock:
token_data = await self.auth_provider.get_token()
if not token_data or "refreshToken" not in token_data:
return None
# Attempt token refresh
new_token_data = await self.auth_provider.refresh(
token_data["refreshToken"]
)
if not new_token_data or "token" not in new_token_data:
return None
# Update request with new authorization header
new_auth_headers = {"Authorization": f"Bearer {new_token_data['token']}"}
request.headers.update(new_auth_headers)
# Retry request with new token
return await client.send(request)
async def request(
self,
method: str,
path: str,
headers: dict[str, str] | None = None,
json_data: dict[str, Any] | None = None,
**kwargs: Any,
) -> httpx.Response:
"""
Make HTTP request with automatic auth and 401 retry logic.
Args:
method: HTTP method (GET, POST, etc.)
path: API path (e.g., "/income")
headers: Additional headers
json_data: JSON request body
**kwargs: Additional httpx.AsyncClient.request arguments
Returns:
httpx.Response object
Raises:
Domain exceptions via raise_for_status()
"""
# Prepare headers
request_headers = self.default_headers.copy()
auth_headers = await self._get_auth_headers()
request_headers.update(auth_headers)
if headers:
request_headers.update(headers)
# Prepare request parameters
request_kwargs = {
"method": method,
"url": self.base_url + path,
"headers": request_headers,
"timeout": self.timeout,
**kwargs,
}
if json_data is not None:
request_kwargs["json"] = json_data
async with httpx.AsyncClient() as client:
# Initial request
response = await client.request(**request_kwargs)
# Handle 401 with token refresh (max 1 retry)
if response.status_code == HTTPStatus.UNAUTHORIZED:
# Build request object for retry
request = client.build_request(**request_kwargs)
retry_response = await self._handle_401_response(client, request)
if retry_response is not None:
response = retry_response
# Check for domain exceptions
raise_for_status(response)
return response
async def get(
self,
path: str,
headers: dict[str, str] | None = None,
**kwargs: Any,
) -> httpx.Response:
"""GET request."""
return await self.request("GET", path, headers=headers, **kwargs)
async def post(
self,
path: str,
json_data: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
**kwargs: Any,
) -> httpx.Response:
"""POST request with JSON data."""
return await self.request(
"POST", path, headers=headers, json_data=json_data, **kwargs
)
async def put(
self,
path: str,
json_data: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
**kwargs: Any,
) -> httpx.Response:
"""PUT request with JSON data."""
return await self.request(
"PUT", path, headers=headers, json_data=json_data, **kwargs
)
async def delete(
self,
path: str,
headers: dict[str, str] | None = None,
**kwargs: Any,
) -> httpx.Response:
"""DELETE request."""
return await self.request("DELETE", path, headers=headers, **kwargs)