Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions examples/auto_token_refresh.py
Original file line number Diff line number Diff line change
@@ -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())
28 changes: 28 additions & 0 deletions finam_trade_api/access/access_token.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
64 changes: 63 additions & 1 deletion finam_trade_api/base_client/base.py
Original file line number Diff line number Diff line change
@@ -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):
"""
Expand All @@ -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):
Expand All @@ -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):
Expand All @@ -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.
Expand All @@ -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()
Expand Down
29 changes: 29 additions & 0 deletions finam_trade_api/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "finam-trade-api"
version = "4.2.6"
version = "4.3.0"
description = "Асинхронный REST-клиент для API Finam"
authors = ["DBoyara <boyarshin.den@yandex.ru>"]
license = "GNU GPL v.3.0"
Expand Down
117 changes: 117 additions & 0 deletions tests/base_client/test_base_client.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Loading