From 78b7a7cd1ac32771c8568efffec0f2b783f0ba84 Mon Sep 17 00:00:00 2001 From: DBoyara Date: Wed, 11 Mar 2026 14:42:23 +0500 Subject: [PATCH] Add automatic JWT token refresh and update tests - Implement automatic JWT token refresh on 401 errors and every 10 minutes - Add set_token_client to BaseClient for token management - Update Client to configure token refresh for all subclients - Add example script for auto token refresh usage - Expand tests to cover auto-refresh logic and edge cases - Bump version to 4.3.0 --- examples/auto_token_refresh.py | 56 ++++++++++++ finam_trade_api/access/access_token.py | 28 ++++++ finam_trade_api/base_client/base.py | 64 +++++++++++++- finam_trade_api/client.py | 29 ++++++ pyproject.toml | 2 +- tests/base_client/test_base_client.py | 117 +++++++++++++++++++++++++ 6 files changed, 294 insertions(+), 2 deletions(-) create mode 100644 examples/auto_token_refresh.py diff --git a/examples/auto_token_refresh.py b/examples/auto_token_refresh.py new file mode 100644 index 0000000..2150310 --- /dev/null +++ b/examples/auto_token_refresh.py @@ -0,0 +1,56 @@ +""" +Пример демонстрирующий автоматическое обновление JWT токена. + +Клиент автоматически обновляет JWT токен в двух случаях: +1. При получении ошибки авторизации (401) от API +2. Каждые 10 минут (превентивное обновление) + +Это позволяет избежать ошибок авторизации при длительной работе с API. +""" + +import asyncio +from finam_trade_api import Client +from finam_trade_api.base_client.token_manager import TokenManager + + +async def main(): + # Токен сгенерированный на https://tradeapi.finam.ru/docs/tokens + token = "your_secret_token_here" + + # Создаем клиент + client = Client(TokenManager(token)) + + # Получаем первичный JWT токен + await client.access_tokens.set_jwt_token() + print("JWT токен успешно получен") + + # Проверяем детали токена + token_details = await client.access_tokens.get_jwt_token_details() + print(f"Детали токена: {token_details}") + + # Теперь можно использовать любые методы API + # JWT токен будет автоматически обновляться: + # - При получении ошибки 401 + # - Каждые 10 минут + + # Пример: получаем список счетов + accounts = await client.account.get_accounts() + print(f"Получены счета: {accounts}") + + # Можно работать длительное время без ручного обновления токена + # Например, запросить данные несколько раз с задержкой + for i in range(3): + print(f"\nЗапрос {i + 1}:") + accounts = await client.account.get_accounts() + print(f"Счетов: {len(accounts.accounts) if hasattr(accounts, 'accounts') else 0}") + + # Задержка между запросами + if i < 2: + await asyncio.sleep(5) + + print("\nВсе запросы выполнены успешно!") + print("JWT токен автоматически обновлялся при необходимости") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/finam_trade_api/access/access_token.py b/finam_trade_api/access/access_token.py index ac37766..2a3b7fb 100644 --- a/finam_trade_api/access/access_token.py +++ b/finam_trade_api/access/access_token.py @@ -26,6 +26,34 @@ def __init__(self, token_manager: TokenManager): super().__init__(token_manager) self._url = "/sessions" + async def _exec_request(self, method: str, url: str, payload=None, **kwargs): + """ + Переопределенный метод выполнения запроса без автоматического обновления токена. + + TokenClient не должен пытаться обновлять токен сам для себя, + чтобы избежать бесконечной рекурсии. + + Параметры: + method (str): HTTP-метод (GET, POST, PUT, DELETE). + url (str): Путь к ресурсу относительно базового URL. + payload (dict | None): Тело запроса в формате JSON. По умолчанию None. + **kwargs: Дополнительные параметры для httpx. + + Возвращает: + tuple[Any, bool]: Кортеж, содержащий JSON-ответ и статус успешности запроса (True/False). + """ + import httpx + + uri = f"{self._base_url}{url}" + + async with httpx.AsyncClient(headers=self._auth_headers, http2=True) as client: + response = await client.request(method, uri, json=payload, **kwargs) + if response.status_code != 200: + if "application/json" not in response.headers.get("content-type", ""): + response.raise_for_status() + return response.json(), False + return response.json(), True + async def set_jwt_token(self): """ Устанавливает JWT-токен, выполняя запрос к API. diff --git a/finam_trade_api/base_client/base.py b/finam_trade_api/base_client/base.py index bf02ffd..27b3597 100644 --- a/finam_trade_api/base_client/base.py +++ b/finam_trade_api/base_client/base.py @@ -1,11 +1,16 @@ +import asyncio from abc import ABC +from datetime import datetime, timedelta from enum import Enum -from typing import Any +from typing import TYPE_CHECKING, Any import httpx from .token_manager import TokenManager +if TYPE_CHECKING: + from finam_trade_api.access.access_token import TokenClient + class BaseClient(ABC): """ @@ -14,6 +19,10 @@ class BaseClient(ABC): Атрибуты: _token_manager (TokenManager): Менеджер токенов для управления JWT-токеном. _base_url (str): Базовый URL для всех запросов. + _token_client (TokenClient | None): Клиент для обновления JWT токена. + _last_token_refresh (datetime | None): Время последнего обновления токена. + _token_refresh_interval (timedelta): Интервал обновления токена (по умолчанию 10 минут). + _refresh_lock (asyncio.Lock): Блокировка для предотвращения одновременного обновления токена. """ class RequestMethod(str, Enum): @@ -35,6 +44,19 @@ def __init__(self, token_manager: TokenManager, url: str = "https://api.finam.ru """ self._token_manager = token_manager self._base_url = url + self._token_client: "TokenClient | None" = None + self._last_token_refresh: datetime | None = None + self._token_refresh_interval = timedelta(minutes=10) + self._refresh_lock = asyncio.Lock() + + def set_token_client(self, token_client: "TokenClient"): + """ + Устанавливает клиент для обновления токенов. + + Параметры: + token_client (TokenClient): Экземпляр TokenClient для обновления JWT токена. + """ + self._token_client = token_client @property def _auth_headers(self): @@ -46,10 +68,40 @@ def _auth_headers(self): """ return {"Authorization": self._token_manager.jwt_token} if self._token_manager.jwt_token else None + def _should_refresh_token(self) -> bool: + """ + Проверяет, нужно ли обновить токен по таймеру. + + Возвращает: + bool: True, если токен нужно обновить, иначе False. + """ + if self._last_token_refresh is None: + return True + return datetime.now() - self._last_token_refresh >= self._token_refresh_interval + + async def _refresh_token(self): + """ + Обновляет JWT токен. + + Использует блокировку для предотвращения одновременного обновления токена + из нескольких запросов. + """ + async with self._refresh_lock: + if self._last_token_refresh is not None: + if datetime.now() - self._last_token_refresh < timedelta(seconds=5): + return + + if self._token_client is not None: + await self._token_client.set_jwt_token() + self._last_token_refresh = datetime.now() + async def _exec_request(self, method: str, url: str, payload=None, **kwargs) -> tuple[Any, bool]: """ Выполняет HTTP-запрос к указанному URL. + Автоматически обновляет JWT токен при получении ошибки авторизации (401) + или если прошло более 10 минут с последнего обновления. + Параметры: method (str): HTTP-метод (GET, POST, PUT, DELETE). url (str): Путь к ресурсу относительно базового URL. @@ -62,10 +114,20 @@ async def _exec_request(self, method: str, url: str, payload=None, **kwargs) -> Исключения: httpx.HTTPError: Если статус ответа не 200 и content_type не "application/json". """ + if self._token_client is not None and self._should_refresh_token(): + await self._refresh_token() + uri = f"{self._base_url}{url}" async with httpx.AsyncClient(headers=self._auth_headers, http2=True) as client: response = await client.request(method, uri, json=payload, **kwargs) + + if response.status_code == 401 and self._token_client is not None: + await self._refresh_token() + + async with httpx.AsyncClient(headers=self._auth_headers, http2=True) as retry_client: + response = await retry_client.request(method, uri, json=payload, **kwargs) + if response.status_code != 200: if "application/json" not in response.headers.get("content-type", ""): response.raise_for_status() diff --git a/finam_trade_api/client.py b/finam_trade_api/client.py index ac63335..fc564cc 100644 --- a/finam_trade_api/client.py +++ b/finam_trade_api/client.py @@ -8,10 +8,39 @@ class Client: + """ + Главный клиент для работы с Finam Trade API. + + Автоматически настраивает обновление JWT токенов для всех подклиентов. + + Атрибуты: + account (AccountClient): Клиент для работы со счетами. + assets (AssetsClient): Клиент для работы с активами. + orders (OrderClient): Клиент для работы с заявками. + access_tokens (TokenClient): Клиент для работы с токенами. + instruments (InstrumentClient): Клиент для работы с инструментами. + quotas (QuotasClient): Клиент для работы с квотами. + """ + def __init__(self, token_manger: TokenManager): + """ + Инициализирует главный клиент и все его подклиенты. + + Автоматически настраивает token_client для всех подклиентов, + чтобы они могли автоматически обновлять JWT токен. + + Параметры: + token_manger (TokenManager): Менеджер токенов для авторизации. + """ self.account = AccountClient(token_manger) self.assets = AssetsClient(token_manger) self.orders = OrderClient(token_manger) self.access_tokens = TokenClient(token_manger) self.instruments = InstrumentClient(token_manger) self.quotas = QuotasClient(token_manger) + + self.account.set_token_client(self.access_tokens) + self.assets.set_token_client(self.access_tokens) + self.orders.set_token_client(self.access_tokens) + self.instruments.set_token_client(self.access_tokens) + self.quotas.set_token_client(self.access_tokens) diff --git a/pyproject.toml b/pyproject.toml index 6eb83b4..e3d90e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "finam-trade-api" -version = "4.2.6" +version = "4.3.0" description = "Асинхронный REST-клиент для API Finam" authors = ["DBoyara "] license = "GNU GPL v.3.0" diff --git a/tests/base_client/test_base_client.py b/tests/base_client/test_base_client.py index 433102d..f4acbe6 100644 --- a/tests/base_client/test_base_client.py +++ b/tests/base_client/test_base_client.py @@ -1,9 +1,11 @@ from unittest.mock import Mock, AsyncMock, patch +from datetime import datetime, timedelta import pytest from finam_trade_api.base_client.base import BaseClient from finam_trade_api.base_client.token_manager import TokenManager +from finam_trade_api.access.access_token import TokenClient @pytest.fixture @@ -49,3 +51,118 @@ async def test_exec_request_failure(mock_session, base_client): response, success = await base_client._exec_request("get", "/test-url") assert success is False assert response == {"error": "not found"} + + +@pytest.mark.asyncio +@patch('httpx.AsyncClient') +async def test_exec_request_auto_refresh_on_401(mock_session, base_client): + """Тест автоматического обновления токена при получении ошибки 401""" + # Создаем мок для TokenClient + token_client = AsyncMock(spec=TokenClient) + token_client.set_jwt_token = AsyncMock() + base_client.set_token_client(token_client) + + # Первый ответ - 401 (неавторизован) + mock_response_401 = AsyncMock() + mock_response_401.status_code = 401 + mock_response_401.headers = {"content-type": "application/json"} + mock_response_401.json = Mock(return_value={"error": "unauthorized"}) + + # Второй ответ - 200 (успех после обновления токена) + mock_response_200 = AsyncMock() + mock_response_200.status_code = 200 + mock_response_200.headers = {"content-type": "application/json"} + mock_response_200.json = Mock(return_value={"key": "value"}) + + mock_session_instance = mock_session.return_value + mock_session_instance.__aenter__.return_value = mock_session_instance + # Первый запрос возвращает 401, второй - 200 + mock_session_instance.request = AsyncMock(side_effect=[mock_response_401, mock_response_200]) + + response, success = await base_client._exec_request("get", "/test-url") + + # Проверяем, что токен был обновлен + token_client.set_jwt_token.assert_called_once() + # Проверяем, что получили успешный ответ + assert success is True + assert response == {"key": "value"} + # Проверяем, что было сделано 2 запроса (первый с ошибкой, второй успешный) + assert mock_session_instance.request.call_count == 2 + + +@pytest.mark.asyncio +@patch('httpx.AsyncClient') +async def test_exec_request_preventive_refresh(mock_session, base_client): + """Тест превентивного обновления токена каждые 10 минут""" + # Создаем мок для TokenClient + token_client = AsyncMock(spec=TokenClient) + token_client.set_jwt_token = AsyncMock() + base_client.set_token_client(token_client) + + # Устанавливаем время последнего обновления в прошлом (более 10 минут назад) + base_client._last_token_refresh = datetime.now() - timedelta(minutes=11) + + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.headers = {"content-type": "application/json"} + mock_response.json = Mock(return_value={"key": "value"}) + + mock_session_instance = mock_session.return_value + mock_session_instance.__aenter__.return_value = mock_session_instance + mock_session_instance.request = AsyncMock(return_value=mock_response) + + response, success = await base_client._exec_request("get", "/test-url") + + # Проверяем, что токен был обновлен превентивно + token_client.set_jwt_token.assert_called_once() + assert success is True + assert response == {"key": "value"} + + +@pytest.mark.asyncio +@patch('httpx.AsyncClient') +async def test_exec_request_no_refresh_when_recent(mock_session, base_client): + """Тест что токен не обновляется, если недавно был обновлен""" + # Создаем мок для TokenClient + token_client = AsyncMock(spec=TokenClient) + token_client.set_jwt_token = AsyncMock() + base_client.set_token_client(token_client) + + # Устанавливаем время последнего обновления недавно (менее 10 минут назад) + base_client._last_token_refresh = datetime.now() - timedelta(minutes=5) + + mock_response = AsyncMock() + mock_response.status_code = 200 + mock_response.headers = {"content-type": "application/json"} + mock_response.json = Mock(return_value={"key": "value"}) + + mock_session_instance = mock_session.return_value + mock_session_instance.__aenter__.return_value = mock_session_instance + mock_session_instance.request = AsyncMock(return_value=mock_response) + + response, success = await base_client._exec_request("get", "/test-url") + + # Проверяем, что токен НЕ был обновлен + token_client.set_jwt_token.assert_not_called() + assert success is True + assert response == {"key": "value"} + + +@pytest.mark.asyncio +async def test_should_refresh_token_when_never_refreshed(base_client): + """Тест что токен должен быть обновлен, если никогда не обновлялся""" + assert base_client._should_refresh_token() is True + + +@pytest.mark.asyncio +async def test_should_refresh_token_when_expired(base_client): + """Тест что токен должен быть обновлен, если прошло более 10 минут""" + base_client._last_token_refresh = datetime.now() - timedelta(minutes=11) + assert base_client._should_refresh_token() is True + + +@pytest.mark.asyncio +async def test_should_not_refresh_token_when_recent(base_client): + """Тест что токен не должен быть обновлен, если обновлялся недавно""" + base_client._last_token_refresh = datetime.now() - timedelta(minutes=5) + assert base_client._should_refresh_token() is False