ctrader-api-client 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ctrader_api_client/__init__.py +64 -0
- ctrader_api_client/_internal/__init__.py +26 -0
- ctrader_api_client/_internal/messages.py +348 -0
- ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +42 -0
- ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +30 -0
- ctrader_api_client/_internal/proto/OpenApiMessages.py +1112 -0
- ctrader_api_client/_internal/proto/OpenApiModelMessages.py +802 -0
- ctrader_api_client/_internal/proto/__init__.py +320 -0
- ctrader_api_client/_internal/serialization.py +84 -0
- ctrader_api_client/api/__init__.py +21 -0
- ctrader_api_client/api/accounts.py +71 -0
- ctrader_api_client/api/market_data.py +424 -0
- ctrader_api_client/api/symbols.py +171 -0
- ctrader_api_client/api/trading.py +506 -0
- ctrader_api_client/auth/__init__.py +14 -0
- ctrader_api_client/auth/credentials.py +72 -0
- ctrader_api_client/auth/manager.py +511 -0
- ctrader_api_client/client.py +475 -0
- ctrader_api_client/config.py +56 -0
- ctrader_api_client/connection/__init__.py +16 -0
- ctrader_api_client/connection/heartbeat.py +120 -0
- ctrader_api_client/connection/protocol.py +366 -0
- ctrader_api_client/connection/transport.py +123 -0
- ctrader_api_client/enums.py +138 -0
- ctrader_api_client/events/__init__.py +65 -0
- ctrader_api_client/events/emitter.py +254 -0
- ctrader_api_client/events/router.py +400 -0
- ctrader_api_client/events/types.py +340 -0
- ctrader_api_client/exceptions.py +231 -0
- ctrader_api_client/models/__init__.py +50 -0
- ctrader_api_client/models/_base.py +19 -0
- ctrader_api_client/models/account.py +177 -0
- ctrader_api_client/models/deal.py +242 -0
- ctrader_api_client/models/market_data.py +192 -0
- ctrader_api_client/models/order.py +262 -0
- ctrader_api_client/models/position.py +209 -0
- ctrader_api_client/models/requests.py +299 -0
- ctrader_api_client/models/symbol.py +194 -0
- ctrader_api_client/py.typed +0 -0
- ctrader_api_client-0.1.0.dist-info/METADATA +252 -0
- ctrader_api_client-0.1.0.dist-info/RECORD +43 -0
- ctrader_api_client-0.1.0.dist-info/WHEEL +4 -0
- ctrader_api_client-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from collections.abc import Awaitable, Callable
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
import anyio
|
|
8
|
+
import anyio.abc
|
|
9
|
+
from tenacity import (
|
|
10
|
+
AsyncRetrying,
|
|
11
|
+
retry_if_exception_type,
|
|
12
|
+
stop_after_attempt,
|
|
13
|
+
wait_exponential,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
from .._internal.proto import (
|
|
17
|
+
ProtoOAAccountAuthReq,
|
|
18
|
+
ProtoOAAccountAuthRes,
|
|
19
|
+
ProtoOAApplicationAuthReq,
|
|
20
|
+
ProtoOAApplicationAuthRes,
|
|
21
|
+
ProtoOAGetAccountListByAccessTokenReq,
|
|
22
|
+
ProtoOAGetAccountListByAccessTokenRes,
|
|
23
|
+
ProtoOARefreshTokenReq,
|
|
24
|
+
ProtoOARefreshTokenRes,
|
|
25
|
+
)
|
|
26
|
+
from ..exceptions import (
|
|
27
|
+
AccountNotFoundError,
|
|
28
|
+
APIError,
|
|
29
|
+
TokenRefreshError,
|
|
30
|
+
)
|
|
31
|
+
from ..models import AccountSummary
|
|
32
|
+
from .credentials import AccountCredentials
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if TYPE_CHECKING:
|
|
36
|
+
from ..connection.protocol import Protocol
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
|
|
41
|
+
TokenRefreshCallback = Callable[[AccountCredentials], Awaitable[None]]
|
|
42
|
+
AccountReadyCallback = Callable[[int, bool], Awaitable[None]] # (account_id, is_reconnect)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AuthManager:
|
|
46
|
+
"""Manages authentication for cTrader API connections.
|
|
47
|
+
|
|
48
|
+
Handles application authentication, account authentication for multiple
|
|
49
|
+
trading accounts, and automatic token refresh before expiry.
|
|
50
|
+
|
|
51
|
+
Example:
|
|
52
|
+
```python
|
|
53
|
+
auth = AuthManager(
|
|
54
|
+
protocol=protocol,
|
|
55
|
+
client_id="your_client_id",
|
|
56
|
+
client_secret="your_client_secret",
|
|
57
|
+
on_tokens_refreshed=save_tokens_to_storage,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
await auth.authenticate_app()
|
|
61
|
+
await auth.authenticate_account(credentials)
|
|
62
|
+
await auth.start() # Start refresh monitor
|
|
63
|
+
|
|
64
|
+
# ... trading operations ...
|
|
65
|
+
|
|
66
|
+
await auth.stop()
|
|
67
|
+
```
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
def __init__(
|
|
71
|
+
self,
|
|
72
|
+
protocol: Protocol,
|
|
73
|
+
client_id: str,
|
|
74
|
+
client_secret: str,
|
|
75
|
+
refresh_buffer_seconds: float = 300.0,
|
|
76
|
+
refresh_check_interval: float = 60.0,
|
|
77
|
+
refresh_retry_attempts: int = 3,
|
|
78
|
+
refresh_retry_min_wait: float = 1.0,
|
|
79
|
+
refresh_retry_max_wait: float = 30.0,
|
|
80
|
+
on_tokens_refreshed: TokenRefreshCallback | None = None,
|
|
81
|
+
on_account_ready: AccountReadyCallback | None = None,
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Initialize the authentication manager.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
protocol: The protocol instance for sending auth requests.
|
|
87
|
+
client_id: OAuth client ID for the application.
|
|
88
|
+
client_secret: OAuth client secret for the application.
|
|
89
|
+
refresh_buffer_seconds: Refresh tokens this many seconds before expiry.
|
|
90
|
+
Defaults to 300 (5 minutes).
|
|
91
|
+
refresh_check_interval: How often to check for expiring tokens (seconds).
|
|
92
|
+
Defaults to 60.
|
|
93
|
+
refresh_retry_attempts: Max retry attempts for token refresh.
|
|
94
|
+
Defaults to 3.
|
|
95
|
+
refresh_retry_min_wait: Initial wait between retries (seconds).
|
|
96
|
+
Defaults to 1.0.
|
|
97
|
+
refresh_retry_max_wait: Maximum wait between retries (seconds).
|
|
98
|
+
Defaults to 30.0.
|
|
99
|
+
on_tokens_refreshed: Async callback invoked when tokens are refreshed.
|
|
100
|
+
Receives the new AccountCredentials. Use this to persist tokens.
|
|
101
|
+
on_account_ready: Async callback invoked when an account is authenticated.
|
|
102
|
+
Receives (account_id, is_reconnect). Use this to perform any initial client setup.
|
|
103
|
+
"""
|
|
104
|
+
self._protocol = protocol
|
|
105
|
+
self._client_id = client_id
|
|
106
|
+
self._client_secret = client_secret
|
|
107
|
+
self._refresh_buffer = refresh_buffer_seconds
|
|
108
|
+
self._check_interval = refresh_check_interval
|
|
109
|
+
self._retry_attempts = refresh_retry_attempts
|
|
110
|
+
self._retry_min_wait = refresh_retry_min_wait
|
|
111
|
+
self._retry_max_wait = refresh_retry_max_wait
|
|
112
|
+
self._on_tokens_refreshed = on_tokens_refreshed
|
|
113
|
+
self._on_account_ready = on_account_ready
|
|
114
|
+
|
|
115
|
+
# Account storage
|
|
116
|
+
self._accounts: dict[int, AccountCredentials] = {}
|
|
117
|
+
|
|
118
|
+
# Background task management
|
|
119
|
+
self._task_group: anyio.abc.TaskGroup | None = None
|
|
120
|
+
self._task_scope: anyio.CancelScope | None = None
|
|
121
|
+
self._running = False
|
|
122
|
+
|
|
123
|
+
# Track app authentication state
|
|
124
|
+
self._app_authenticated = False
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def is_app_authenticated(self) -> bool:
|
|
128
|
+
"""Whether the application has been authenticated."""
|
|
129
|
+
return self._app_authenticated
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def authenticated_accounts(self) -> list[int]:
|
|
133
|
+
"""List of authenticated account IDs."""
|
|
134
|
+
return list(self._accounts.keys())
|
|
135
|
+
|
|
136
|
+
def get_credentials(self, account_id: int) -> AccountCredentials | None:
|
|
137
|
+
"""Get credentials for an account.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
account_id: The cTID trader account ID.
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
The account credentials, or None if not authenticated.
|
|
144
|
+
"""
|
|
145
|
+
return self._accounts.get(account_id)
|
|
146
|
+
|
|
147
|
+
async def authenticate_app(self, timeout: float = 30.0) -> ProtoOAApplicationAuthRes:
|
|
148
|
+
"""Authenticate the application with cTrader.
|
|
149
|
+
|
|
150
|
+
This must be called before authenticating any accounts.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
timeout: Request timeout in seconds.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
The authentication response from the server.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
APIError: If authentication fails.
|
|
160
|
+
CTraderConnectionTimeoutError: If request times out.
|
|
161
|
+
"""
|
|
162
|
+
logger.info("Authenticating application")
|
|
163
|
+
|
|
164
|
+
request = ProtoOAApplicationAuthReq(
|
|
165
|
+
client_id=self._client_id,
|
|
166
|
+
client_secret=self._client_secret,
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
response = await self._protocol.send_request(request, timeout=timeout)
|
|
170
|
+
|
|
171
|
+
if not isinstance(response, ProtoOAApplicationAuthRes):
|
|
172
|
+
raise APIError(
|
|
173
|
+
error_code="UNEXPECTED_RESPONSE",
|
|
174
|
+
description=f"Expected ProtoOAApplicationAuthRes, got {type(response).__name__}",
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
self._app_authenticated = True
|
|
178
|
+
logger.info("Application authenticated successfully")
|
|
179
|
+
return response
|
|
180
|
+
|
|
181
|
+
async def authenticate_account(
|
|
182
|
+
self,
|
|
183
|
+
credentials: AccountCredentials,
|
|
184
|
+
timeout: float = 30.0,
|
|
185
|
+
reauth: bool = False,
|
|
186
|
+
) -> ProtoOAAccountAuthRes:
|
|
187
|
+
"""Authenticate a trading account.
|
|
188
|
+
|
|
189
|
+
The account credentials are stored for automatic token refresh.
|
|
190
|
+
|
|
191
|
+
Args:
|
|
192
|
+
credentials: The account credentials including tokens.
|
|
193
|
+
timeout: Request timeout in seconds.
|
|
194
|
+
reauth: Whether this is a re-authentication (token refresh) or initial auth.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
The authentication response from the server.
|
|
198
|
+
|
|
199
|
+
Raises:
|
|
200
|
+
APIError: If authentication fails.
|
|
201
|
+
CTraderConnectionTimeoutError: If request times out.
|
|
202
|
+
"""
|
|
203
|
+
if reauth:
|
|
204
|
+
logger.info("Re-authenticating account %d", credentials.account_id)
|
|
205
|
+
else:
|
|
206
|
+
logger.info("Authenticating account %d", credentials.account_id)
|
|
207
|
+
|
|
208
|
+
request = ProtoOAAccountAuthReq(
|
|
209
|
+
ctid_trader_account_id=credentials.account_id,
|
|
210
|
+
access_token=credentials.access_token,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
response = await self._protocol.send_request(request, timeout=timeout)
|
|
214
|
+
|
|
215
|
+
if not isinstance(response, ProtoOAAccountAuthRes):
|
|
216
|
+
raise APIError(
|
|
217
|
+
error_code="UNEXPECTED_RESPONSE",
|
|
218
|
+
description=f"Expected ProtoOAAccountAuthRes, got {type(response).__name__}",
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
# Store credentials for refresh monitoring
|
|
222
|
+
self._accounts[credentials.account_id] = credentials
|
|
223
|
+
logger.info("Account %d authenticated successfully", credentials.account_id)
|
|
224
|
+
|
|
225
|
+
# Notify callback
|
|
226
|
+
if self._on_account_ready is not None:
|
|
227
|
+
try:
|
|
228
|
+
await self._on_account_ready(credentials.account_id, reauth)
|
|
229
|
+
except Exception as e:
|
|
230
|
+
logger.warning(
|
|
231
|
+
"Account ready callback failed for account %d: %s",
|
|
232
|
+
credentials.account_id,
|
|
233
|
+
e,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
return response
|
|
237
|
+
|
|
238
|
+
async def get_accounts(
|
|
239
|
+
self,
|
|
240
|
+
access_token: str,
|
|
241
|
+
timeout: float = 30.0,
|
|
242
|
+
) -> list[AccountSummary]:
|
|
243
|
+
"""Get all trading accounts associated with an access token.
|
|
244
|
+
|
|
245
|
+
This retrieves the list of accounts without authenticating them.
|
|
246
|
+
Useful for discovering available accounts or letting users select
|
|
247
|
+
which account to use.
|
|
248
|
+
|
|
249
|
+
Args:
|
|
250
|
+
access_token: OAuth access token.
|
|
251
|
+
timeout: Request timeout in seconds.
|
|
252
|
+
|
|
253
|
+
Returns:
|
|
254
|
+
List of account summaries (lightweight account info).
|
|
255
|
+
|
|
256
|
+
Raises:
|
|
257
|
+
APIError: If the request fails.
|
|
258
|
+
CTraderConnectionTimeoutError: If request times out.
|
|
259
|
+
"""
|
|
260
|
+
logger.debug("Fetching accounts for access token")
|
|
261
|
+
|
|
262
|
+
request = ProtoOAGetAccountListByAccessTokenReq(
|
|
263
|
+
access_token=access_token,
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
response = await self._protocol.send_request(request, timeout=timeout)
|
|
267
|
+
|
|
268
|
+
if not isinstance(response, ProtoOAGetAccountListByAccessTokenRes):
|
|
269
|
+
raise APIError(
|
|
270
|
+
error_code="UNEXPECTED_RESPONSE",
|
|
271
|
+
description=f"Expected ProtoOAGetAccountListByAccessTokenRes, got {type(response).__name__}",
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
accounts = [AccountSummary.from_proto(acc) for acc in response.ctid_trader_account]
|
|
275
|
+
logger.debug("Found %d accounts", len(accounts))
|
|
276
|
+
return accounts
|
|
277
|
+
|
|
278
|
+
async def resolve_account_id(
|
|
279
|
+
self,
|
|
280
|
+
access_token: str,
|
|
281
|
+
trader_login: int,
|
|
282
|
+
timeout: float = 30.0,
|
|
283
|
+
) -> int:
|
|
284
|
+
"""Resolve a trader login to its cTID trader account ID.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
access_token: OAuth access token.
|
|
288
|
+
trader_login: The trader login number (visible in cTrader app).
|
|
289
|
+
timeout: Request timeout in seconds.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
The cTID trader account ID (used for API calls).
|
|
293
|
+
|
|
294
|
+
Raises:
|
|
295
|
+
AccountNotFoundError: If no account matches the trader login.
|
|
296
|
+
APIError: If the request fails.
|
|
297
|
+
CTraderConnectionTimeoutError: If request times out.
|
|
298
|
+
"""
|
|
299
|
+
accounts = await self.get_accounts(access_token, timeout=timeout)
|
|
300
|
+
|
|
301
|
+
for account in accounts:
|
|
302
|
+
if account.trader_login == trader_login:
|
|
303
|
+
logger.debug(
|
|
304
|
+
"Resolved trader login %d to account ID %d",
|
|
305
|
+
trader_login,
|
|
306
|
+
account.account_id,
|
|
307
|
+
)
|
|
308
|
+
return account.account_id
|
|
309
|
+
|
|
310
|
+
available_logins = [acc.trader_login for acc in accounts]
|
|
311
|
+
raise AccountNotFoundError(trader_login, available_logins)
|
|
312
|
+
|
|
313
|
+
async def authenticate_by_trader_login(
|
|
314
|
+
self,
|
|
315
|
+
trader_login: int,
|
|
316
|
+
access_token: str,
|
|
317
|
+
refresh_token: str,
|
|
318
|
+
expires_at: float,
|
|
319
|
+
timeout: float = 30.0,
|
|
320
|
+
) -> AccountCredentials:
|
|
321
|
+
"""Authenticate an account using trader login (discovers cTID automatically).
|
|
322
|
+
|
|
323
|
+
This is a convenience method that:
|
|
324
|
+
1. Resolves the trader login to the cTID trader account ID
|
|
325
|
+
2. Creates AccountCredentials with the resolved ID
|
|
326
|
+
3. Authenticates the account
|
|
327
|
+
|
|
328
|
+
Args:
|
|
329
|
+
trader_login: The trader login number (visible in cTrader app).
|
|
330
|
+
access_token: OAuth access token.
|
|
331
|
+
refresh_token: OAuth refresh token.
|
|
332
|
+
expires_at: Unix timestamp when access token expires.
|
|
333
|
+
timeout: Request timeout in seconds.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
AccountCredentials with the resolved account_id, ready for use.
|
|
337
|
+
|
|
338
|
+
Raises:
|
|
339
|
+
AccountNotFoundError: If no account matches the trader login.
|
|
340
|
+
APIError: If authentication fails.
|
|
341
|
+
CTraderConnectionTimeoutError: If request times out.
|
|
342
|
+
"""
|
|
343
|
+
logger.info("Authenticating by trader login %d", trader_login)
|
|
344
|
+
|
|
345
|
+
# Resolve trader_login to account_id
|
|
346
|
+
account_id = await self.resolve_account_id(access_token, trader_login, timeout=timeout)
|
|
347
|
+
|
|
348
|
+
# Create credentials with resolved ID
|
|
349
|
+
credentials = AccountCredentials(
|
|
350
|
+
account_id=account_id,
|
|
351
|
+
access_token=access_token,
|
|
352
|
+
refresh_token=refresh_token,
|
|
353
|
+
expires_at=expires_at,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
# Authenticate the account
|
|
357
|
+
await self.authenticate_account(credentials, timeout=timeout)
|
|
358
|
+
|
|
359
|
+
return credentials
|
|
360
|
+
|
|
361
|
+
def remove_account(self, account_id: int) -> bool:
|
|
362
|
+
"""Remove an account from refresh monitoring.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
account_id: The cTID trader account ID.
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
True if the account was removed, False if it wasn't registered.
|
|
369
|
+
"""
|
|
370
|
+
if account_id in self._accounts:
|
|
371
|
+
del self._accounts[account_id]
|
|
372
|
+
logger.info("Account %d removed from auth manager", account_id)
|
|
373
|
+
return True
|
|
374
|
+
return False
|
|
375
|
+
|
|
376
|
+
async def start(self) -> None:
|
|
377
|
+
"""Start the token refresh monitor.
|
|
378
|
+
|
|
379
|
+
This runs a background task that periodically checks for expiring
|
|
380
|
+
tokens and refreshes them automatically.
|
|
381
|
+
"""
|
|
382
|
+
if self._running:
|
|
383
|
+
return
|
|
384
|
+
|
|
385
|
+
self._running = True
|
|
386
|
+
self._task_group = anyio.create_task_group()
|
|
387
|
+
await self._task_group.__aenter__()
|
|
388
|
+
self._task_group.start_soon(self._refresh_loop)
|
|
389
|
+
logger.debug("Token refresh monitor started")
|
|
390
|
+
|
|
391
|
+
async def stop(self) -> None:
|
|
392
|
+
"""Stop the token refresh monitor."""
|
|
393
|
+
self._running = False
|
|
394
|
+
|
|
395
|
+
if self._task_scope is not None:
|
|
396
|
+
self._task_scope.cancel()
|
|
397
|
+
|
|
398
|
+
if self._task_group is not None:
|
|
399
|
+
self._task_group.cancel_scope.cancel()
|
|
400
|
+
try:
|
|
401
|
+
await self._task_group.__aexit__(None, None, None)
|
|
402
|
+
except Exception:
|
|
403
|
+
pass
|
|
404
|
+
self._task_group = None
|
|
405
|
+
|
|
406
|
+
logger.debug("Token refresh monitor stopped")
|
|
407
|
+
|
|
408
|
+
async def _refresh_loop(self) -> None:
|
|
409
|
+
"""Periodically check and refresh expiring tokens."""
|
|
410
|
+
with anyio.CancelScope() as scope:
|
|
411
|
+
self._task_scope = scope
|
|
412
|
+
while self._running:
|
|
413
|
+
await anyio.sleep(self._check_interval)
|
|
414
|
+
|
|
415
|
+
for account_id in list(self._accounts.keys()):
|
|
416
|
+
credentials = self._accounts.get(account_id)
|
|
417
|
+
if credentials is None:
|
|
418
|
+
continue
|
|
419
|
+
|
|
420
|
+
if credentials.expires_soon(self._refresh_buffer):
|
|
421
|
+
logger.info(
|
|
422
|
+
"Token for account %d expires soon (%.0fs remaining), refreshing",
|
|
423
|
+
account_id,
|
|
424
|
+
credentials.time_until_expiry(),
|
|
425
|
+
)
|
|
426
|
+
try:
|
|
427
|
+
await self._refresh_account(account_id)
|
|
428
|
+
except TokenRefreshError as e:
|
|
429
|
+
logger.error("Failed to refresh token for account %d: %s", account_id, e)
|
|
430
|
+
raise
|
|
431
|
+
|
|
432
|
+
async def _refresh_account(self, account_id: int) -> None:
|
|
433
|
+
"""Refresh tokens for an account with retry logic.
|
|
434
|
+
|
|
435
|
+
Args:
|
|
436
|
+
account_id: The cTID trader account ID.
|
|
437
|
+
|
|
438
|
+
Raises:
|
|
439
|
+
TokenRefreshError: If refresh fails after all retries.
|
|
440
|
+
"""
|
|
441
|
+
credentials = self._accounts.get(account_id)
|
|
442
|
+
if credentials is None:
|
|
443
|
+
return
|
|
444
|
+
|
|
445
|
+
last_error: Exception | None = None
|
|
446
|
+
|
|
447
|
+
try:
|
|
448
|
+
async for attempt in AsyncRetrying(
|
|
449
|
+
stop=stop_after_attempt(self._retry_attempts),
|
|
450
|
+
wait=wait_exponential(
|
|
451
|
+
min=self._retry_min_wait,
|
|
452
|
+
max=self._retry_max_wait,
|
|
453
|
+
),
|
|
454
|
+
retry=retry_if_exception_type(APIError),
|
|
455
|
+
reraise=True,
|
|
456
|
+
):
|
|
457
|
+
with attempt:
|
|
458
|
+
logger.debug(
|
|
459
|
+
"Token refresh attempt %d/%d for account %d",
|
|
460
|
+
attempt.retry_state.attempt_number,
|
|
461
|
+
self._retry_attempts,
|
|
462
|
+
account_id,
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
# Send refresh request
|
|
466
|
+
request = ProtoOARefreshTokenReq(
|
|
467
|
+
refresh_token=credentials.refresh_token,
|
|
468
|
+
)
|
|
469
|
+
response = await self._protocol.send_request(request)
|
|
470
|
+
|
|
471
|
+
if not isinstance(response, ProtoOARefreshTokenRes):
|
|
472
|
+
raise APIError(
|
|
473
|
+
error_code="UNEXPECTED_RESPONSE",
|
|
474
|
+
description=f"Expected ProtoOARefreshTokenRes, got {type(response).__name__}",
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
# Update credentials
|
|
478
|
+
new_credentials = credentials.with_refreshed_tokens(
|
|
479
|
+
access_token=response.access_token,
|
|
480
|
+
refresh_token=response.refresh_token,
|
|
481
|
+
expires_in=response.expires_in,
|
|
482
|
+
)
|
|
483
|
+
self._accounts[account_id] = new_credentials
|
|
484
|
+
|
|
485
|
+
logger.info(
|
|
486
|
+
"Token refreshed for account %d, new expiry in %ds",
|
|
487
|
+
account_id,
|
|
488
|
+
response.expires_in,
|
|
489
|
+
)
|
|
490
|
+
|
|
491
|
+
# Re-authenticate the account with the new token
|
|
492
|
+
await self.authenticate_account(new_credentials, reauth=True)
|
|
493
|
+
|
|
494
|
+
# Notify callback
|
|
495
|
+
if self._on_tokens_refreshed is not None:
|
|
496
|
+
try:
|
|
497
|
+
await self._on_tokens_refreshed(new_credentials)
|
|
498
|
+
except Exception as e:
|
|
499
|
+
logger.warning(
|
|
500
|
+
"Token refresh callback failed for account %d: %s",
|
|
501
|
+
account_id,
|
|
502
|
+
e,
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
except APIError as e:
|
|
506
|
+
last_error = e
|
|
507
|
+
except Exception as e:
|
|
508
|
+
last_error = e
|
|
509
|
+
|
|
510
|
+
if last_error is not None:
|
|
511
|
+
raise TokenRefreshError(account_id, last_error)
|