ctrader-api-client 0.10.0__tar.gz → 0.11.0__tar.gz
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-0.10.0 → ctrader_api_client-0.11.0}/PKG-INFO +4 -1
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/README.md +3 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/api/client.md +3 -1
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/api/exceptions.md +6 -4
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/getting-started.md +6 -0
- ctrader_api_client-0.11.0/plans/startup-token-refresh.md +157 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/pyproject.toml +1 -1
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/auth/_maintain.py +5 -40
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/auth/manager.py +66 -12
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/composition.py +1 -2
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/exceptions.py +2 -2
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/auth/conftest.py +16 -8
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/auth/test_authentication.py +41 -2
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/auth/test_token_lifecycle.py +34 -14
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/uv.lock +1 -1
- ctrader_api_client-0.10.0/plans/connection-auth-refactor.md +0 -99
- ctrader_api_client-0.10.0/plans/connection-research.md +0 -43
- ctrader_api_client-0.10.0/plans/strict-enum-mapping.md +0 -65
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/.claude/settings.local.json +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/.complexipy_cache/.gitignore +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/.complexipy_cache/CACHEDIR.TAG +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/.complexipy_cache/README.md +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/.complexipy_cache/v/cache/functions +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/.github/workflows/docs.yml +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/.gitignore +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/.pre-commit-config.yaml +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/.python-version +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/Justfile +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/LICENSE +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/api/accounts.md +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/api/enums.md +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/api/events.md +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/api/market-data.md +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/api/models.md +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/api/symbols.md +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/api/trading.md +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/index.md +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/mkdocs.yml +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/protos/SOURCE +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/protos/VERSION +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/protos/update.sh +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/protos/vendor/OpenApiCommonMessages.proto +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/protos/vendor/OpenApiMessages.proto +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/protos/vendor/OpenApiModelMessages.proto +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/scripts/fix_proto_imports.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/__init__.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/__init__.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/backoff.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/clock.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/conversions.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/messages.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/serialization.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/api/__init__.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/api/_base.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/api/accounts.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/api/market_data.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/api/symbols.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/api/trading.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/auth/__init__.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/auth/_session.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/auth/credentials.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/auth/policy.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/auth/store.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/client.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/config.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/connection/__init__.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/connection/heartbeat.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/connection/protocol.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/connection/supervisor.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/connection/transport.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/enums.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/events/__init__.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/events/_execution.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/events/emitter.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/events/publisher.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/events/router.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/events/types.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/__init__.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/_base.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/account.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/commission.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/deal.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/market_data.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/order.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/position.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/requests.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/symbol.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/trading_hours.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/py.typed +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/__init__.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/__init__.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/api/__init__.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/api/conftest.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/api/test_market_data.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/api/test_reference_data.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/api/test_subscription_restore.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/api/test_trading.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/auth/__init__.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/connection/__init__.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/connection/conftest.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/connection/test_event_dispatch.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/connection/test_framing.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/connection/test_heartbeat.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/connection/test_reconnection.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/connection/test_request_response.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/events/__init__.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/events/conftest.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/events/test_routing.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/events/test_subscriptions.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/test_client.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/conftest.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/harness/__init__.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/harness/clock.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/harness/factories.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/harness/recorder.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/harness/server.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/harness/signals.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/harness/stub_protocol.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/harness/wire.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/unit/__init__.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/unit/test_api_errors.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/unit/test_commission_conversion.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/unit/test_enum_mapping.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/unit/test_market_data_conversion.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/unit/test_request_conversion.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/unit/test_symbol_conversion.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/unit/test_trading_hours.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/unit/test_trading_records.py +0 -0
- {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/unit/test_wire_format.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: ctrader-api-client
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.11.0
|
|
4
4
|
Summary: API Client to interact with the cTrader Open API spec
|
|
5
5
|
Author-email: Elio <elioachukri@pm.me>
|
|
6
6
|
License-File: LICENSE
|
|
@@ -133,6 +133,9 @@ await client.auth.authenticate_trader(
|
|
|
133
133
|
# Tokens are automatically refreshed before expiry. A refresh that fails is
|
|
134
134
|
# retried on the next check rather than dropping the session, and surfaces as a
|
|
135
135
|
# TokenRefreshFailedEvent so a persistently dead refresh token is observable.
|
|
136
|
+
# An access token that has already expired is refreshed before the account is
|
|
137
|
+
# authenticated, so a process that starts after the expiry needs no new token.
|
|
138
|
+
# Only a refresh token the server refuses raises TokenExpiredError.
|
|
136
139
|
```
|
|
137
140
|
|
|
138
141
|
### Persisting Rotated Tokens
|
|
@@ -114,6 +114,9 @@ await client.auth.authenticate_trader(
|
|
|
114
114
|
# Tokens are automatically refreshed before expiry. A refresh that fails is
|
|
115
115
|
# retried on the next check rather than dropping the session, and surfaces as a
|
|
116
116
|
# TokenRefreshFailedEvent so a persistently dead refresh token is observable.
|
|
117
|
+
# An access token that has already expired is refreshed before the account is
|
|
118
|
+
# authenticated, so a process that starts after the expiry needs no new token.
|
|
119
|
+
# Only a refresh token the server refuses raises TokenExpiredError.
|
|
117
120
|
```
|
|
118
121
|
|
|
119
122
|
### Persisting Rotated Tokens
|
|
@@ -70,7 +70,9 @@ Token-refresh and session-recovery timing, passed via
|
|
|
70
70
|
|
|
71
71
|
The `client.auth` property provides access to authentication operations. The
|
|
72
72
|
application is authenticated as the client connects, so what is left here is
|
|
73
|
-
authenticating trading accounts and asking after the sessions they hold.
|
|
73
|
+
authenticating trading accounts and asking after the sessions they hold. An
|
|
74
|
+
account whose access token has expired is refreshed before it is authenticated,
|
|
75
|
+
so the refresh token in the credentials is what a restart depends on.
|
|
74
76
|
|
|
75
77
|
::: ctrader_api_client.auth.AuthManager
|
|
76
78
|
options:
|
|
@@ -19,7 +19,7 @@ CTraderError
|
|
|
19
19
|
├── AuthenticationError
|
|
20
20
|
│ ├── ApplicationAuthError the server rejected client_id/secret
|
|
21
21
|
│ ├── AccountAuthError the server rejected the account
|
|
22
|
-
│ ├── TokenExpiredError the
|
|
22
|
+
│ ├── TokenExpiredError the server refused the access or refresh token
|
|
23
23
|
│ ├── TokenRefreshError a refresh exhausted its retries
|
|
24
24
|
│ └── AccountNotFoundError no account matches that trader login
|
|
25
25
|
├── APIError an error response to a request
|
|
@@ -69,9 +69,11 @@ except AccountAuthError as e:
|
|
|
69
69
|
print(f"Account {e.ctid_trader_account_id} rejected: {e.error_code}")
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
`TokenExpiredError`
|
|
73
|
-
|
|
74
|
-
token.
|
|
72
|
+
`TokenExpiredError` means the server refused a token: the access token at
|
|
73
|
+
authentication, or the refresh token when the client refreshed an expired access
|
|
74
|
+
token first. Neither can be retried back to life. It needs a new OAuth
|
|
75
|
+
authorization. An access token that has merely expired does not raise, because
|
|
76
|
+
the client refreshes it before it authenticates the account.
|
|
75
77
|
|
|
76
78
|
### Requests
|
|
77
79
|
|
|
@@ -235,6 +235,12 @@ The contract is write-only. Reading back at startup is yours, since only you kno
|
|
|
235
235
|
which accounts a given process is responsible for. See
|
|
236
236
|
[TokenStore](api/client.md#tokenstore) for the full contract.
|
|
237
237
|
|
|
238
|
+
A stored pair whose access token has expired still works. `authenticate_trader`
|
|
239
|
+
refreshes it first and saves the new pair through the store, so a process that
|
|
240
|
+
starts after the expiry needs no new token from the outside. Only a refresh token
|
|
241
|
+
that the server refuses raises `TokenExpiredError`, and that needs a new OAuth
|
|
242
|
+
authorization.
|
|
243
|
+
|
|
238
244
|
## Handling Errors
|
|
239
245
|
|
|
240
246
|
Everything the library raises derives from `CTraderError`. Bringing the client up
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# Startup token refresh
|
|
2
|
+
|
|
3
|
+
Status: complete on 2026-09-27 in 0.11.0, commits `ee431d9`, `de5037f`,
|
|
4
|
+
`5869d89`, `92c2f79`, and `ec6d29f`. The consumer follow-up is pending.
|
|
5
|
+
|
|
6
|
+
## Problem
|
|
7
|
+
|
|
8
|
+
An expired access token is terminal, although the credentials that hold it also
|
|
9
|
+
hold a refresh token that would replace it.
|
|
10
|
+
|
|
11
|
+
- `AuthManager.establish` raises `TokenExpiredError` when `credentials.is_expired()`
|
|
12
|
+
is true. It sends no request first.
|
|
13
|
+
- The refresh exchange lives only in `SessionMaintainer._rotate`. Only the refresh
|
|
14
|
+
loop reaches it, and the loop runs only while the client is open.
|
|
15
|
+
|
|
16
|
+
So a token that expires while no client is open is dead on the next start. This
|
|
17
|
+
hits every process that is not a long-lived daemon. A daily job that runs for
|
|
18
|
+
minutes never sits in the five-minute refresh window, so its token always expires
|
|
19
|
+
between runs. A long-lived process hits the same wall after a link outage that
|
|
20
|
+
spans the expiry: `prepare` re-establishes each account with the expired token and
|
|
21
|
+
fails.
|
|
22
|
+
|
|
23
|
+
## Facts from the source
|
|
24
|
+
|
|
25
|
+
- `auth/manager.py`: `establish` guards with `is_expired()` before the
|
|
26
|
+
`ProtoOAAccountAuthReq`. `authenticate_trader`, `SessionMaintainer.prepare`,
|
|
27
|
+
`SessionMaintainer._recover`, and `SessionMaintainer._rotate` all call it.
|
|
28
|
+
- `auth/_maintain.py`: `_rotate` sends `ProtoOARefreshTokenReq`, maps a token
|
|
29
|
+
failure to `TokenExpiredError`, calls `manager.replace_credentials`, saves through
|
|
30
|
+
the token store, then calls `establish` with `AuthTrigger.TOKEN_REFRESH`. The
|
|
31
|
+
new pair goes into memory before the save, because the server has invalidated
|
|
32
|
+
the old pair.
|
|
33
|
+
- `_refresh` wraps `_rotate` with the retry policy and the refresh window
|
|
34
|
+
(`_refreshing`). The window exists so that the disconnect report the server sends
|
|
35
|
+
for the old authorization is not read as a drop.
|
|
36
|
+
- `AuthManager.replace_credentials` creates a `TrackedAccount` with no session for
|
|
37
|
+
an account id it does not hold.
|
|
38
|
+
- `tests/behavior/auth/test_token_lifecycle.py` pins the refresh loop: the request
|
|
39
|
+
carries the current refresh token, the new pair is saved, a dead refresh token is
|
|
40
|
+
not retried, a store that cannot save aborts the refresh, and a check refused over
|
|
41
|
+
the token leaves the account alone.
|
|
42
|
+
- `tests/behavior/auth/test_authentication.py::test_an_already_expired_token_is_not_sent_to_the_server`
|
|
43
|
+
pins the current terminal behaviour.
|
|
44
|
+
- `RefreshPolicy.buffer_seconds` drives the loop only.
|
|
45
|
+
|
|
46
|
+
## Design
|
|
47
|
+
|
|
48
|
+
1. `AuthManager` gains `refresh(credentials, timeout=None) -> AccountCredentials`.
|
|
49
|
+
It sends the refresh request, maps a token failure to `TokenExpiredError`,
|
|
50
|
+
calls `replace_credentials`, and saves through the token store. This is the
|
|
51
|
+
body of `_rotate` today, moved. The manager takes `token_store`; the
|
|
52
|
+
maintainer no longer does.
|
|
53
|
+
2. `establish` replaces the guard `if is_expired: raise` with
|
|
54
|
+
`credentials = await self.refresh(credentials)`. Every path that establishes a
|
|
55
|
+
session gets the behaviour from the one place that owned the decision. No
|
|
56
|
+
caller changes.
|
|
57
|
+
3. `SessionMaintainer._rotate` becomes: `rotated = await manager.refresh(c)`, then
|
|
58
|
+
`establish(rotated, TOKEN_REFRESH)`. The retry policy, the refresh window, and
|
|
59
|
+
the `TokenRefreshFailedEvent` stay in the maintainer. The maintainer drops the
|
|
60
|
+
token store and the proto imports.
|
|
61
|
+
4. `replace_credentials` does nothing for an account id it does not hold. The
|
|
62
|
+
name promises a replacement, and an account tracked before it ever
|
|
63
|
+
authenticated is a latent bug: `prepare` would try to re-establish it.
|
|
64
|
+
5. The library ships no `TokenStore` implementation. The protocol stays
|
|
65
|
+
write-only, and each consumer keeps its own store, because only the consumer
|
|
66
|
+
knows which accounts a process holds and where they live. A shipped file
|
|
67
|
+
store was in an earlier version of this plan and was dropped on 2026-09-27:
|
|
68
|
+
only one consumer wants a file, and a `load_all` on a shipped store would
|
|
69
|
+
assert a one-file-one-process model that the library has no business
|
|
70
|
+
asserting.
|
|
71
|
+
|
|
72
|
+
### What does not change
|
|
73
|
+
|
|
74
|
+
- `refresh` makes one attempt. A refused refresh token raises `TokenExpiredError`.
|
|
75
|
+
Another refusal raises the `APIError`. A timeout propagates. The maintainer keeps
|
|
76
|
+
its retry-and-report wrapping around the same call.
|
|
77
|
+
- The new pair goes into memory before the save. A store that cannot save raises,
|
|
78
|
+
and the account is not authenticated, because a pair the store never accepted
|
|
79
|
+
strands the next restart. This is the stance the lifecycle tests pin today.
|
|
80
|
+
- No refresh window at the three new refresh points. No live authorization exists
|
|
81
|
+
there: the account is not tracked yet, or `prepare` invalidated it, or it awaits
|
|
82
|
+
recovery. The existing test that a check refused over the token leaves the
|
|
83
|
+
account alone covers the case where a report arrives anyway.
|
|
84
|
+
- `_recover` defers on any exception, with no attempt limit. A dead refresh token
|
|
85
|
+
is retried the same way an expired token is today.
|
|
86
|
+
- `buffer_seconds` plays no part at startup. Only `is_expired` triggers a refresh
|
|
87
|
+
in `establish`. A token with time left is established as today, and the loop
|
|
88
|
+
handles "expires soon" with its retries.
|
|
89
|
+
|
|
90
|
+
### Attribute counts
|
|
91
|
+
|
|
92
|
+
- `AuthManager`: 7 today, 8 after (`_token_store`).
|
|
93
|
+
- `SessionMaintainer`: 9 today, 7 after (`_token_store` leaves, and `_protocol`
|
|
94
|
+
leaves with it, because the refresh exchange was its only use). Agreed during
|
|
95
|
+
step 2.
|
|
96
|
+
|
|
97
|
+
## Steps
|
|
98
|
+
|
|
99
|
+
One step per turn. Each step leaves the suite green.
|
|
100
|
+
|
|
101
|
+
### Step 1: `replace_credentials` ignores an unknown account
|
|
102
|
+
|
|
103
|
+
`auth/manager.py` only. One new test in `test_authentication.py`: replacing the
|
|
104
|
+
credentials of an account the manager does not hold leaves the manager without
|
|
105
|
+
that account.
|
|
106
|
+
|
|
107
|
+
### Step 2: move the refresh exchange to the manager
|
|
108
|
+
|
|
109
|
+
- `AuthManager.__init__` takes `token_store: TokenStore | None = None`.
|
|
110
|
+
- Add `AuthManager.refresh`.
|
|
111
|
+
- `SessionMaintainer.__init__` drops `token_store`. `_rotate` calls
|
|
112
|
+
`manager.refresh`. Remove the proto imports and `TokenStore` import from
|
|
113
|
+
`_maintain.py`.
|
|
114
|
+
- `composition.build_graph` passes `token_store` to `AuthManager`.
|
|
115
|
+
- `tests/behavior/auth/conftest.py`: a `store` fixture returns a `RecordingStore`.
|
|
116
|
+
`make_auth` passes it as `token_store`. `make_maintainer` accepts a `manager`
|
|
117
|
+
override for a test that builds its own manager. The tests that construct a
|
|
118
|
+
`RecordingStore` build the manager with it through `make_auth` instead of
|
|
119
|
+
passing it to `make_maintainer`.
|
|
120
|
+
|
|
121
|
+
Every assertion in `test_token_lifecycle.py` passes unchanged.
|
|
122
|
+
|
|
123
|
+
### Step 3: `establish` refreshes an expired token
|
|
124
|
+
|
|
125
|
+
- `auth/manager.py`: the guard becomes the refresh call.
|
|
126
|
+
- Replace `test_an_already_expired_token_is_not_sent_to_the_server` in
|
|
127
|
+
`test_authentication.py` with:
|
|
128
|
+
1. An expired token is refreshed before the account is authenticated: the auth
|
|
129
|
+
request carries the new token, the store saved it, and the expired token was
|
|
130
|
+
not sent.
|
|
131
|
+
2. An expired token whose refresh is refused raises `TokenExpiredError`, and no
|
|
132
|
+
auth request is sent.
|
|
133
|
+
3. A token with time left is not refreshed at authentication.
|
|
134
|
+
- Add to `test_token_lifecycle.py`:
|
|
135
|
+
4. A new link refreshes an expired account before it re-establishes it:
|
|
136
|
+
`maintainer.prepare()` with an expired tracked account sends the refresh
|
|
137
|
+
request, then the auth request with the new token.
|
|
138
|
+
|
|
139
|
+
### Step 4: docs and version
|
|
140
|
+
|
|
141
|
+
- `README.md` and `docs/getting-started.md`: state that `authenticate_trader`
|
|
142
|
+
refreshes an expired access token, and what it raises. The file store example
|
|
143
|
+
in the getting-started guide stays as consumer code.
|
|
144
|
+
- `docs/api/exceptions.md`: `TokenExpiredError` now means a token the server
|
|
145
|
+
refused. A token the client knows has expired is refreshed instead. Added
|
|
146
|
+
during step 3.
|
|
147
|
+
- `pyproject.toml`: 0.11.0.
|
|
148
|
+
|
|
149
|
+
## Consumer follow-up
|
|
150
|
+
|
|
151
|
+
Out of scope for this repository. Recorded so the two changes stay aligned.
|
|
152
|
+
|
|
153
|
+
The framework bumps its pin to 0.11.0 and adds its own file-backed `TokenStore`
|
|
154
|
+
with a `CTRADER_TOKEN_FILE` setting, default `.ctrader-tokens.json`. It saves
|
|
155
|
+
the bootstrap pair from `.env` on the first run after it resolves the account
|
|
156
|
+
id, and reads the stored pair back on later runs. A publish deployment has one
|
|
157
|
+
broker account, so a file with more than one entry is an error.
|
{ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/auth/_maintain.py
RENAMED
|
@@ -9,13 +9,11 @@ that the server drops, and prepares each new link.
|
|
|
9
9
|
from __future__ import annotations
|
|
10
10
|
|
|
11
11
|
import logging
|
|
12
|
-
from typing import TYPE_CHECKING
|
|
13
12
|
|
|
14
13
|
import anyio
|
|
15
14
|
import anyio.abc
|
|
16
15
|
|
|
17
16
|
from .._internal import Clock, MonotonicClock, backoff_delay
|
|
18
|
-
from .._internal.proto import ProtoOARefreshTokenReq, ProtoOARefreshTokenRes
|
|
19
17
|
from ..enums import AuthTrigger
|
|
20
18
|
from ..events import (
|
|
21
19
|
AccountDisconnectEvent,
|
|
@@ -24,16 +22,11 @@ from ..events import (
|
|
|
24
22
|
ReconnectedEvent,
|
|
25
23
|
TokenRefreshFailedEvent,
|
|
26
24
|
)
|
|
27
|
-
from ..exceptions import AccountAuthError, APIError,
|
|
25
|
+
from ..exceptions import AccountAuthError, APIError, TokenRefreshError
|
|
28
26
|
from ._session import AwaitingRecovery
|
|
29
27
|
from .credentials import AccountCredentials
|
|
30
28
|
from .manager import AuthManager
|
|
31
29
|
from .policy import ReauthPolicy, RefreshPolicy
|
|
32
|
-
from .store import TokenStore
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
if TYPE_CHECKING:
|
|
36
|
-
from ..connection.protocol import Protocol
|
|
37
30
|
|
|
38
31
|
|
|
39
32
|
logger = logging.getLogger(__name__)
|
|
@@ -66,34 +59,27 @@ class SessionMaintainer:
|
|
|
66
59
|
|
|
67
60
|
def __init__(
|
|
68
61
|
self,
|
|
69
|
-
protocol: Protocol,
|
|
70
62
|
manager: AuthManager,
|
|
71
63
|
publisher: EventPublisher,
|
|
72
64
|
refresh_policy: RefreshPolicy | None = None,
|
|
73
65
|
reauth_policy: ReauthPolicy | None = None,
|
|
74
66
|
clock: Clock | None = None,
|
|
75
|
-
token_store: TokenStore | None = None,
|
|
76
67
|
) -> None:
|
|
77
68
|
"""Initialize the maintainer.
|
|
78
69
|
|
|
79
70
|
Args:
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
the accounts.
|
|
71
|
+
manager: Holds the accounts, refreshes their tokens, and
|
|
72
|
+
authenticates the application and the accounts.
|
|
83
73
|
publisher: Where the events are published.
|
|
84
74
|
refresh_policy: When to refresh access tokens, and how hard to try.
|
|
85
75
|
reauth_policy: Backoff for the sessions that the server dropped.
|
|
86
76
|
clock: Time source for the refresh interval and the backoff.
|
|
87
|
-
token_store: Durable storage for rotated credentials. Without one,
|
|
88
|
-
refreshed tokens live only in memory.
|
|
89
77
|
"""
|
|
90
|
-
self._protocol = protocol
|
|
91
78
|
self._manager = manager
|
|
92
79
|
self._publisher = publisher
|
|
93
80
|
self._refresh_policy = refresh_policy if refresh_policy is not None else RefreshPolicy()
|
|
94
81
|
self._reauth_policy = reauth_policy if reauth_policy is not None else ReauthPolicy()
|
|
95
82
|
self._clock = clock if clock is not None else MonotonicClock()
|
|
96
|
-
self._token_store = token_store
|
|
97
83
|
|
|
98
84
|
# Set when an account needs recovery. The recovery loop replaces it
|
|
99
85
|
# each time it wakes.
|
|
@@ -307,32 +293,11 @@ class SessionMaintainer:
|
|
|
307
293
|
settled.set()
|
|
308
294
|
|
|
309
295
|
async def _rotate(self, credentials: AccountCredentials) -> None:
|
|
310
|
-
"""Make one refresh exchange
|
|
311
|
-
|
|
312
|
-
The server invalidates the old pair as it issues the new one, so the
|
|
313
|
-
new pair is kept before the store saves it.
|
|
296
|
+
"""Make one refresh exchange and re-authorize the account with the new pair.
|
|
314
297
|
|
|
315
298
|
Raises:
|
|
316
299
|
TokenExpiredError: If the refresh token is dead.
|
|
317
300
|
APIError: If the server refuses the request for another reason.
|
|
318
301
|
"""
|
|
319
|
-
|
|
320
|
-
try:
|
|
321
|
-
response = await self._protocol.request(request, ProtoOARefreshTokenRes)
|
|
322
|
-
except APIError as e:
|
|
323
|
-
if e.is_token_failure():
|
|
324
|
-
raise TokenExpiredError(credentials.account_id) from e
|
|
325
|
-
raise
|
|
326
|
-
|
|
327
|
-
rotated = credentials.with_refreshed_tokens(
|
|
328
|
-
access_token=response.access_token,
|
|
329
|
-
refresh_token=response.refresh_token,
|
|
330
|
-
expires_in=response.expires_in,
|
|
331
|
-
)
|
|
332
|
-
self._manager.replace_credentials(rotated)
|
|
333
|
-
|
|
334
|
-
if self._token_store is not None:
|
|
335
|
-
await self._token_store.save(rotated)
|
|
336
|
-
|
|
337
|
-
logger.debug("Token refreshed for account %d, new expiry in %ds", credentials.account_id, response.expires_in)
|
|
302
|
+
rotated = await self._manager.refresh(credentials)
|
|
338
303
|
await self._manager.establish(rotated, AuthTrigger.TOKEN_REFRESH)
|
{ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/auth/manager.py
RENAMED
|
@@ -15,6 +15,8 @@ from .._internal.proto import (
|
|
|
15
15
|
ProtoOAAccountAuthRes,
|
|
16
16
|
ProtoOAApplicationAuthReq,
|
|
17
17
|
ProtoOAApplicationAuthRes,
|
|
18
|
+
ProtoOARefreshTokenReq,
|
|
19
|
+
ProtoOARefreshTokenRes,
|
|
18
20
|
ProtoOATraderReq,
|
|
19
21
|
ProtoOATraderRes,
|
|
20
22
|
)
|
|
@@ -28,6 +30,7 @@ from ..exceptions import (
|
|
|
28
30
|
)
|
|
29
31
|
from ._session import Authorized, AwaitingRecovery, TrackedAccount
|
|
30
32
|
from .credentials import AccountCredentials
|
|
33
|
+
from .store import TokenStore
|
|
31
34
|
|
|
32
35
|
|
|
33
36
|
if TYPE_CHECKING:
|
|
@@ -75,6 +78,7 @@ class AuthManager:
|
|
|
75
78
|
client_id: str,
|
|
76
79
|
client_secret: str,
|
|
77
80
|
restorer: SessionRestorer | None = None,
|
|
81
|
+
token_store: TokenStore | None = None,
|
|
78
82
|
) -> None:
|
|
79
83
|
"""Initialize the authentication manager.
|
|
80
84
|
|
|
@@ -85,12 +89,15 @@ class AuthManager:
|
|
|
85
89
|
client_secret: OAuth client secret for the application.
|
|
86
90
|
restorer: Re-applies whatever a fresh session starts without, before
|
|
87
91
|
the account is announced as ready.
|
|
92
|
+
token_store: Durable storage for rotated credentials. Without one,
|
|
93
|
+
refreshed tokens live only in memory.
|
|
88
94
|
"""
|
|
89
95
|
self._protocol = protocol
|
|
90
96
|
self._publisher = publisher
|
|
91
97
|
self._client_id = client_id
|
|
92
98
|
self._client_secret = client_secret
|
|
93
99
|
self._restorer = restorer
|
|
100
|
+
self._token_store = token_store
|
|
94
101
|
self._app_authenticated = False
|
|
95
102
|
self._accounts: dict[int, TrackedAccount] = {}
|
|
96
103
|
|
|
@@ -154,16 +161,15 @@ class AuthManager:
|
|
|
154
161
|
self._accounts[credentials.account_id] = TrackedAccount(credentials, Authorized())
|
|
155
162
|
|
|
156
163
|
def replace_credentials(self, credentials: AccountCredentials) -> None:
|
|
157
|
-
"""Swap in rotated tokens. Leave the session state as it was.
|
|
164
|
+
"""Swap in rotated tokens for an account the manager holds. Leave the session state as it was.
|
|
158
165
|
|
|
159
166
|
Args:
|
|
160
167
|
credentials: The rotated credentials.
|
|
161
168
|
"""
|
|
162
169
|
current = self._accounts.get(credentials.account_id)
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
)
|
|
170
|
+
if current is None:
|
|
171
|
+
return
|
|
172
|
+
self._accounts[credentials.account_id] = TrackedAccount(credentials, current.session)
|
|
167
173
|
|
|
168
174
|
def invalidate_all(self) -> None:
|
|
169
175
|
"""Forget every server-side session. Keep the credentials.
|
|
@@ -255,16 +261,19 @@ class AuthManager:
|
|
|
255
261
|
) -> None:
|
|
256
262
|
"""Authenticate a trading account and keep its session alive.
|
|
257
263
|
|
|
258
|
-
|
|
259
|
-
expire and the session is
|
|
264
|
+
An expired access token is refreshed first. The credentials are kept,
|
|
265
|
+
so the tokens are refreshed before they expire and the session is
|
|
266
|
+
re-established after a disconnect.
|
|
260
267
|
|
|
261
268
|
Args:
|
|
262
269
|
credentials: The account credentials including tokens.
|
|
263
270
|
timeout: Request timeout in seconds. None uses the configured default.
|
|
264
271
|
|
|
265
272
|
Raises:
|
|
266
|
-
TokenExpiredError: If the access token
|
|
267
|
-
|
|
273
|
+
TokenExpiredError: If the server rejects the access token, or if
|
|
274
|
+
the access token has expired and the server rejects the
|
|
275
|
+
refresh token.
|
|
276
|
+
APIError: If the server refuses a refresh for another reason.
|
|
268
277
|
AccountAuthError: If the server rejects the account.
|
|
269
278
|
CTraderConnectionTimeoutError: If request times out.
|
|
270
279
|
"""
|
|
@@ -278,6 +287,9 @@ class AuthManager:
|
|
|
278
287
|
) -> None:
|
|
279
288
|
"""Authorize an account on the current link.
|
|
280
289
|
|
|
290
|
+
An expired access token is refreshed first, so a process that starts
|
|
291
|
+
or reconnects after the expiry needs no new token from the outside.
|
|
292
|
+
|
|
281
293
|
Args:
|
|
282
294
|
credentials: The account credentials including tokens.
|
|
283
295
|
trigger: Why the authentication happens. A token refresh renews
|
|
@@ -287,8 +299,10 @@ class AuthManager:
|
|
|
287
299
|
timeout: Request timeout in seconds. None uses the configured default.
|
|
288
300
|
|
|
289
301
|
Raises:
|
|
290
|
-
TokenExpiredError: If the access token
|
|
291
|
-
|
|
302
|
+
TokenExpiredError: If the server rejects the access token, or if
|
|
303
|
+
the access token has expired and the server rejects the
|
|
304
|
+
refresh token.
|
|
305
|
+
APIError: If the server refuses a refresh for another reason.
|
|
292
306
|
AccountAuthError: If the server rejects the account.
|
|
293
307
|
CTraderConnectionTimeoutError: If request times out.
|
|
294
308
|
"""
|
|
@@ -298,7 +312,8 @@ class AuthManager:
|
|
|
298
312
|
logger.debug("Re-authenticating account %d (%s)", credentials.account_id, trigger.value)
|
|
299
313
|
|
|
300
314
|
if credentials.is_expired():
|
|
301
|
-
|
|
315
|
+
logger.debug("Access token for account %d has expired; refreshing first", credentials.account_id)
|
|
316
|
+
credentials = await self.refresh(credentials, timeout=timeout)
|
|
302
317
|
|
|
303
318
|
request = ProtoOAAccountAuthReq(
|
|
304
319
|
ctid_trader_account_id=credentials.account_id,
|
|
@@ -323,6 +338,45 @@ class AuthManager:
|
|
|
323
338
|
await self._restorer.restore(credentials.account_id)
|
|
324
339
|
await self._publisher.emit(ReadyEvent(account_id=credentials.account_id, trigger=trigger))
|
|
325
340
|
|
|
341
|
+
async def refresh(self, credentials: AccountCredentials, timeout: float | None = None) -> AccountCredentials:
|
|
342
|
+
"""Rotate the tokens of an account and keep the new pair.
|
|
343
|
+
|
|
344
|
+
The server invalidates the old pair as it issues the new one, so the new
|
|
345
|
+
pair replaces the held one before the store saves it.
|
|
346
|
+
|
|
347
|
+
Args:
|
|
348
|
+
credentials: The credentials whose refresh token is presented.
|
|
349
|
+
timeout: Request timeout in seconds. None uses the configured default.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
The rotated credentials.
|
|
353
|
+
|
|
354
|
+
Raises:
|
|
355
|
+
TokenExpiredError: If the refresh token is dead.
|
|
356
|
+
APIError: If the server refuses the request for another reason.
|
|
357
|
+
CTraderConnectionTimeoutError: If the request times out.
|
|
358
|
+
"""
|
|
359
|
+
request = ProtoOARefreshTokenReq(refresh_token=credentials.refresh_token)
|
|
360
|
+
try:
|
|
361
|
+
response = await self._protocol.request(request, ProtoOARefreshTokenRes, timeout=timeout)
|
|
362
|
+
except APIError as e:
|
|
363
|
+
if e.is_token_failure():
|
|
364
|
+
raise TokenExpiredError(credentials.account_id) from e
|
|
365
|
+
raise
|
|
366
|
+
|
|
367
|
+
rotated = credentials.with_refreshed_tokens(
|
|
368
|
+
access_token=response.access_token,
|
|
369
|
+
refresh_token=response.refresh_token,
|
|
370
|
+
expires_in=response.expires_in,
|
|
371
|
+
)
|
|
372
|
+
self.replace_credentials(rotated)
|
|
373
|
+
|
|
374
|
+
if self._token_store is not None:
|
|
375
|
+
await self._token_store.save(rotated)
|
|
376
|
+
|
|
377
|
+
logger.debug("Token refreshed for account %d, new expiry in %ds", credentials.account_id, response.expires_in)
|
|
378
|
+
return rotated
|
|
379
|
+
|
|
326
380
|
async def probe_session(self, account_id: int, timeout: float | None = None) -> bool:
|
|
327
381
|
"""Return whether the account still holds a live session on this link.
|
|
328
382
|
|
{ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/composition.py
RENAMED
|
@@ -91,16 +91,15 @@ def build_graph(
|
|
|
91
91
|
client_id=config.client_id,
|
|
92
92
|
client_secret=config.client_secret,
|
|
93
93
|
restorer=market_data,
|
|
94
|
+
token_store=token_store,
|
|
94
95
|
)
|
|
95
96
|
|
|
96
97
|
maintainer = SessionMaintainer(
|
|
97
|
-
protocol=protocol,
|
|
98
98
|
manager=auth,
|
|
99
99
|
publisher=emitter,
|
|
100
100
|
refresh_policy=config.refresh_policy,
|
|
101
101
|
reauth_policy=config.reauth_policy,
|
|
102
102
|
clock=clock,
|
|
103
|
-
token_store=token_store,
|
|
104
103
|
)
|
|
105
104
|
|
|
106
105
|
router = EventRouter(
|
{ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/exceptions.py
RENAMED
|
@@ -151,11 +151,11 @@ class AccountAuthError(AuthenticationError):
|
|
|
151
151
|
|
|
152
152
|
|
|
153
153
|
class TokenExpiredError(AuthenticationError):
|
|
154
|
-
"""
|
|
154
|
+
"""The server refused the access token, or the refresh token that was to replace it."""
|
|
155
155
|
|
|
156
156
|
def __init__(self, ctid_trader_account_id: int | None = None) -> None:
|
|
157
157
|
self.ctid_trader_account_id = ctid_trader_account_id
|
|
158
|
-
message = "
|
|
158
|
+
message = "Token refused by the server"
|
|
159
159
|
if ctid_trader_account_id:
|
|
160
160
|
message += f" for account {ctid_trader_account_id}"
|
|
161
161
|
super().__init__(message)
|
|
@@ -13,7 +13,7 @@ from ctrader_api_client.auth import (
|
|
|
13
13
|
SessionMaintainer,
|
|
14
14
|
)
|
|
15
15
|
|
|
16
|
-
from ...harness import ManualClock, RecordingPublisher, StubProtocol
|
|
16
|
+
from ...harness import ManualClock, RecordingPublisher, RecordingStore, StubProtocol
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
@pytest.fixture
|
|
@@ -22,18 +22,26 @@ def publisher() -> RecordingPublisher:
|
|
|
22
22
|
return RecordingPublisher()
|
|
23
23
|
|
|
24
24
|
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def store() -> RecordingStore:
|
|
27
|
+
"""Records every pair the manager under test persists."""
|
|
28
|
+
return RecordingStore()
|
|
29
|
+
|
|
30
|
+
|
|
25
31
|
@pytest.fixture
|
|
26
32
|
def make_auth(
|
|
27
33
|
protocol: StubProtocol,
|
|
28
34
|
publisher: RecordingPublisher,
|
|
35
|
+
store: RecordingStore,
|
|
29
36
|
) -> Callable[..., AuthManager]:
|
|
30
|
-
"""Build auth managers."""
|
|
37
|
+
"""Build auth managers that persist through the recording store."""
|
|
31
38
|
|
|
32
39
|
def factory(**overrides: object) -> AuthManager:
|
|
33
40
|
settings: dict[str, object] = {
|
|
34
41
|
"publisher": publisher,
|
|
35
42
|
"client_id": "test-client-id",
|
|
36
43
|
"client_secret": "test-client-secret",
|
|
44
|
+
"token_store": store,
|
|
37
45
|
}
|
|
38
46
|
settings.update(overrides)
|
|
39
47
|
return AuthManager(protocol=protocol, **settings) # type: ignore[arg-type]
|
|
@@ -49,13 +57,15 @@ def auth(make_auth: Callable[..., AuthManager]) -> AuthManager:
|
|
|
49
57
|
|
|
50
58
|
@pytest.fixture
|
|
51
59
|
def make_maintainer(
|
|
52
|
-
protocol: StubProtocol,
|
|
53
60
|
clock: ManualClock,
|
|
54
61
|
publisher: RecordingPublisher,
|
|
55
62
|
auth: AuthManager,
|
|
56
63
|
serving: Callable[[SessionMaintainer], None],
|
|
57
64
|
) -> Callable[..., SessionMaintainer]:
|
|
58
|
-
"""Build maintainers over
|
|
65
|
+
"""Build maintainers over an auth manager, with their loops already running.
|
|
66
|
+
|
|
67
|
+
The default manager is the `auth` fixture. A test that builds its own
|
|
68
|
+
manager, for example over a store that fails, passes it as `manager`.
|
|
59
69
|
|
|
60
70
|
Every wait runs on the injected clock. The refresh retry wait is zero
|
|
61
71
|
unless a test needs the retry to park, and the check interval stays
|
|
@@ -66,11 +76,10 @@ def make_maintainer(
|
|
|
66
76
|
check_interval: float = 60.0,
|
|
67
77
|
retry_wait: float = 0.0,
|
|
68
78
|
reauth_policy: ReauthPolicy | None = None,
|
|
69
|
-
|
|
79
|
+
manager: AuthManager | None = None,
|
|
70
80
|
) -> SessionMaintainer:
|
|
71
81
|
maintainer = SessionMaintainer(
|
|
72
|
-
|
|
73
|
-
manager=auth,
|
|
82
|
+
manager=manager if manager is not None else auth,
|
|
74
83
|
publisher=publisher,
|
|
75
84
|
refresh_policy=RefreshPolicy(
|
|
76
85
|
check_interval=check_interval,
|
|
@@ -79,7 +88,6 @@ def make_maintainer(
|
|
|
79
88
|
),
|
|
80
89
|
reauth_policy=reauth_policy if reauth_policy is not None else ReauthPolicy(min_wait=0.001, max_wait=0.01),
|
|
81
90
|
clock=clock,
|
|
82
|
-
**overrides, # type: ignore[arg-type]
|
|
83
91
|
)
|
|
84
92
|
serving(maintainer)
|
|
85
93
|
return maintainer
|