ctrader-api-client 0.7.0__tar.gz → 0.7.2__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.7.0 → ctrader_api_client-0.7.2}/.gitignore +4 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/PKG-INFO +1 -1
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/api/events.md +19 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/pyproject.toml +1 -1
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/_recovery.py +93 -8
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/_refresh.py +7 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/_session.py +38 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/manager.py +39 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/events/router.py +11 -10
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/events/types.py +11 -5
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/exceptions.py +9 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/auth/test_token_lifecycle.py +172 -23
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/events/test_routing.py +3 -2
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/test_client.py +40 -1
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/harness/factories.py +10 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/harness/recorder.py +1 -1
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/uv.lock +1 -1
- ctrader_api_client-0.7.0/HARDENING.md +0 -251
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/.claude/settings.local.json +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/.github/workflows/docs.yml +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/.pre-commit-config.yaml +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/.python-version +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/Justfile +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/LICENSE +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/README.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/api/accounts.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/api/client.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/api/enums.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/api/exceptions.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/api/market-data.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/api/models.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/api/symbols.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/api/trading.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/getting-started.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/index.md +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/mkdocs.yml +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/protos/SOURCE +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/protos/VERSION +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/protos/update.sh +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/protos/vendor/OpenApiCommonMessages.proto +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/protos/vendor/OpenApiMessages.proto +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/protos/vendor/OpenApiModelMessages.proto +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/scripts/fix_proto_imports.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/clock.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/conversions.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/messages.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/serialization.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/api/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/api/_base.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/api/accounts.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/api/market_data.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/api/symbols.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/api/trading.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/credentials.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/policy.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/store.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/client.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/composition.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/config.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/connection/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/connection/heartbeat.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/connection/listener.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/connection/protocol.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/connection/supervisor.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/connection/transport.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/enums.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/events/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/events/_execution.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/events/emitter.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/events/publisher.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/_base.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/account.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/deal.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/market_data.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/order.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/position.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/requests.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/symbol.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/py.typed +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/api/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/api/conftest.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/api/test_market_data.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/api/test_reference_data.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/api/test_subscription_restore.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/api/test_trading.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/auth/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/auth/conftest.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/auth/test_authentication.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/connection/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/connection/conftest.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/connection/test_event_dispatch.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/connection/test_framing.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/connection/test_heartbeat.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/connection/test_reconnection.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/connection/test_request_response.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/events/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/events/conftest.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/events/test_subscriptions.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/conftest.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/harness/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/harness/clock.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/harness/server.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/harness/signals.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/harness/stub_protocol.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/harness/wire.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/unit/__init__.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/unit/test_api_errors.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/unit/test_market_data_conversion.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/unit/test_request_conversion.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/unit/test_symbol_conversion.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/unit/test_trading_records.py +0 -0
- {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/unit/test_wire_format.py +0 -0
|
@@ -159,6 +159,25 @@ the existing connection with backoff and emits a `ReadyEvent` on success. The
|
|
|
159
159
|
event is informational; subscribe to it only if you want to observe or log the
|
|
160
160
|
drop. Check current authorization with `client.is_account_authorized(account_id)`.
|
|
161
161
|
|
|
162
|
+
A disconnect the server reports is checked before it is published. Rotating an
|
|
163
|
+
access token makes the server report one for a session it has not ended, so the
|
|
164
|
+
client asks whether the account is still served on this connection: if it is,
|
|
165
|
+
nothing is published. The check does not re-authenticate, because that answers
|
|
166
|
+
for the token as much as for the session, and the rotation prompting the report
|
|
167
|
+
is the very thing that would make such an answer unreliable.
|
|
168
|
+
|
|
169
|
+
The check also waits for any token refresh on that account to finish first.
|
|
170
|
+
Rotating a token means the server drops the old authorization and the client
|
|
171
|
+
establishes a new one, and in between the account is genuinely unauthorized — it
|
|
172
|
+
refuses everything, including the check. Since the refresh always begins before
|
|
173
|
+
the report it provokes, waiting for it to settle is what keeps a moment the
|
|
174
|
+
client itself created from reading as a lost session.
|
|
175
|
+
|
|
176
|
+
A check that cannot be completed — a timeout, a link that has just gone — is not
|
|
177
|
+
read as a disconnect either. The account is left as it was, and the next report
|
|
178
|
+
is checked afresh. What reaches a handler is therefore always a session the
|
|
179
|
+
server confirmed it was no longer serving.
|
|
180
|
+
|
|
162
181
|
::: ctrader_api_client.events.TokenInvalidatedEvent
|
|
163
182
|
options:
|
|
164
183
|
show_source: false
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/_recovery.py
RENAMED
|
@@ -14,7 +14,8 @@ import anyio.abc
|
|
|
14
14
|
|
|
15
15
|
from .._internal import Clock, MonotonicClock
|
|
16
16
|
from ..enums import AuthTrigger
|
|
17
|
-
from ..events import EventPublisher, ReconnectedEvent
|
|
17
|
+
from ..events import AccountDisconnectEvent, EventPublisher, ReadyEvent, ReconnectedEvent
|
|
18
|
+
from ..exceptions import AccountAuthError
|
|
18
19
|
from ._session import AwaitingRecovery, SessionAuthenticator, SessionStore
|
|
19
20
|
from .policy import ReauthPolicy
|
|
20
21
|
|
|
@@ -55,6 +56,10 @@ class SessionRecovery:
|
|
|
55
56
|
# Wakes the recovery monitor when an account needs re-authenticating
|
|
56
57
|
self._reauth_signal = anyio.Event()
|
|
57
58
|
|
|
59
|
+
# Accounts whose reported disconnect is still being checked, so a
|
|
60
|
+
# repeated report does not start a second check against the server.
|
|
61
|
+
self._verifying: set[int] = set()
|
|
62
|
+
|
|
58
63
|
self._task_scope: anyio.CancelScope | None = None
|
|
59
64
|
self._running = False
|
|
60
65
|
|
|
@@ -90,24 +95,84 @@ class SessionRecovery:
|
|
|
90
95
|
if self._task_scope is not None:
|
|
91
96
|
self._task_scope.cancel()
|
|
92
97
|
|
|
93
|
-
def handle_account_disconnect(self, account_id: int) -> None:
|
|
94
|
-
"""
|
|
98
|
+
async def handle_account_disconnect(self, account_id: int) -> None:
|
|
99
|
+
"""Establish whether a reported account disconnect actually happened.
|
|
100
|
+
|
|
101
|
+
The server reports a disconnect whenever the token behind an
|
|
102
|
+
authorization is rotated, even though the session on this channel is
|
|
103
|
+
untouched. Taking the report at face value would mark a working account
|
|
104
|
+
unauthorized and publish a drop that never occurred, so the report is
|
|
105
|
+
checked against the server before anything acts on it.
|
|
106
|
+
|
|
107
|
+
Every report the server sends for a token rotation arrives while the
|
|
108
|
+
client is replacing that authorization, and for the length of that
|
|
109
|
+
replacement the account really is unauthorized: it answers nothing, and
|
|
110
|
+
asking it anything would only confirm a state the client is already
|
|
111
|
+
undoing. So the check waits for the refresh to settle before it asks.
|
|
112
|
+
The refresh always starts before the report it provokes, so there is no
|
|
113
|
+
ordering in which the report escapes the wait.
|
|
95
114
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
115
|
+
The check itself asks whether the account is still served on this link
|
|
116
|
+
rather than trying to re-authorize it, since re-authorizing answers for
|
|
117
|
+
the token as much as for the session.
|
|
118
|
+
|
|
119
|
+
A check that cannot be completed is not an answer of no. The account is
|
|
120
|
+
left as it was, because nothing has said the session ended.
|
|
99
121
|
|
|
100
122
|
Args:
|
|
101
123
|
account_id: The cTID trader account ID reported as disconnected.
|
|
102
124
|
"""
|
|
103
|
-
if
|
|
125
|
+
if self._store.is_refreshing(account_id):
|
|
126
|
+
logger.debug(
|
|
127
|
+
"Account %d was reported as disconnected while its token was being "
|
|
128
|
+
"refreshed; waiting for that to settle before checking",
|
|
129
|
+
account_id,
|
|
130
|
+
)
|
|
131
|
+
await self._store.wait_for_refresh(account_id)
|
|
132
|
+
|
|
133
|
+
credentials = self._store.credentials(account_id)
|
|
134
|
+
if credentials is None or not self._store.is_authorized(account_id):
|
|
104
135
|
return
|
|
105
136
|
|
|
106
|
-
self.
|
|
137
|
+
if account_id in self._verifying:
|
|
138
|
+
return
|
|
139
|
+
|
|
140
|
+
self._verifying.add(account_id)
|
|
141
|
+
try:
|
|
142
|
+
try:
|
|
143
|
+
alive = await self._authenticator.probe_session(account_id)
|
|
144
|
+
except Exception as e:
|
|
145
|
+
logger.debug(
|
|
146
|
+
"Could not check the disconnect reported for account %d, leaving it as it is: %s",
|
|
147
|
+
account_id,
|
|
148
|
+
e,
|
|
149
|
+
)
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
if alive:
|
|
153
|
+
logger.debug(
|
|
154
|
+
"Account %d reported as disconnected still holds its session; ignoring",
|
|
155
|
+
account_id,
|
|
156
|
+
)
|
|
157
|
+
return
|
|
158
|
+
|
|
159
|
+
await self._report_dropped(account_id)
|
|
160
|
+
finally:
|
|
161
|
+
self._verifying.discard(account_id)
|
|
162
|
+
|
|
163
|
+
async def _report_dropped(self, account_id: int) -> None:
|
|
164
|
+
"""Publish a confirmed drop and arm recovery for it.
|
|
165
|
+
|
|
166
|
+
Reached once the server has stopped serving the account on this link, so
|
|
167
|
+
the session is genuinely gone.
|
|
168
|
+
"""
|
|
107
169
|
logger.warning(
|
|
108
170
|
"Account %d disconnected by server; scheduling re-authentication",
|
|
109
171
|
account_id,
|
|
110
172
|
)
|
|
173
|
+
self._store.flag_for_recovery(account_id)
|
|
174
|
+
self._reauth_signal.set()
|
|
175
|
+
await self._publisher.emit(AccountDisconnectEvent(account_id=account_id))
|
|
111
176
|
|
|
112
177
|
async def on_connection_lost(self) -> None:
|
|
113
178
|
"""Discard the sessions that died with the link.
|
|
@@ -229,6 +294,26 @@ class SessionRecovery:
|
|
|
229
294
|
for account_id, credentials, state in due:
|
|
230
295
|
try:
|
|
231
296
|
await self._authenticator.establish(credentials, AuthTrigger.ACCOUNT_REAUTH)
|
|
297
|
+
except AccountAuthError as e:
|
|
298
|
+
if not e.is_already_authorized():
|
|
299
|
+
logger.warning(
|
|
300
|
+
"Recovery re-authentication for account %d failed, will retry: %s",
|
|
301
|
+
account_id,
|
|
302
|
+
e,
|
|
303
|
+
)
|
|
304
|
+
self._defer_recovery(account_id, state)
|
|
305
|
+
continue
|
|
306
|
+
# The session came back by some other route between
|
|
307
|
+
# attempts. Retrying could only be refused the same way, so
|
|
308
|
+
# take the account as recovered and announce it, noting that
|
|
309
|
+
# nothing re-applied the subscriptions the dead session took
|
|
310
|
+
# with it.
|
|
311
|
+
logger.warning(
|
|
312
|
+
"Account %d holds a session again; its subscriptions were not re-applied",
|
|
313
|
+
account_id,
|
|
314
|
+
)
|
|
315
|
+
self._store.authorize(credentials)
|
|
316
|
+
await self._publisher.emit(ReadyEvent(account_id=account_id, trigger=AuthTrigger.ACCOUNT_REAUTH))
|
|
232
317
|
except Exception as e:
|
|
233
318
|
logger.warning(
|
|
234
319
|
"Recovery re-authentication for account %d failed, will retry: %s",
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/_refresh.py
RENAMED
|
@@ -142,6 +142,11 @@ class TokenRefresher:
|
|
|
142
142
|
|
|
143
143
|
last_error: Exception | None = None
|
|
144
144
|
|
|
145
|
+
# Held from before the request goes out until the account is authorized
|
|
146
|
+
# again, because the disconnect the server reports for the old token
|
|
147
|
+
# arrives inside that window and means nothing about the session.
|
|
148
|
+
self._store.begin_refresh(account_id)
|
|
149
|
+
|
|
145
150
|
try:
|
|
146
151
|
async for attempt in AsyncRetrying(
|
|
147
152
|
stop=stop_after_attempt(self._policy.retry_attempts),
|
|
@@ -198,6 +203,8 @@ class TokenRefresher:
|
|
|
198
203
|
last_error = e
|
|
199
204
|
except Exception as e:
|
|
200
205
|
last_error = e
|
|
206
|
+
finally:
|
|
207
|
+
self._store.end_refresh(account_id)
|
|
201
208
|
|
|
202
209
|
if last_error is not None:
|
|
203
210
|
raise TokenRefreshError(account_id, last_error)
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/_session.py
RENAMED
|
@@ -17,6 +17,8 @@ from __future__ import annotations
|
|
|
17
17
|
from dataclasses import dataclass
|
|
18
18
|
from typing import Protocol as TypingProtocol
|
|
19
19
|
|
|
20
|
+
import anyio
|
|
21
|
+
|
|
20
22
|
from ..enums import AuthTrigger
|
|
21
23
|
from .credentials import AccountCredentials
|
|
22
24
|
|
|
@@ -77,6 +79,15 @@ class SessionAuthenticator(TypingProtocol):
|
|
|
77
79
|
"""Authorize an account, recording why so the right events follow."""
|
|
78
80
|
...
|
|
79
81
|
|
|
82
|
+
async def probe_session(self, account_id: int, timeout: float = 10.0) -> bool:
|
|
83
|
+
"""Whether the account still holds a live session on this link.
|
|
84
|
+
|
|
85
|
+
Raises whatever stopped the question from being answered — including a
|
|
86
|
+
refusal that is about the token rather than the session — which is not
|
|
87
|
+
the same as an answer of no.
|
|
88
|
+
"""
|
|
89
|
+
...
|
|
90
|
+
|
|
80
91
|
|
|
81
92
|
class SessionStore:
|
|
82
93
|
"""Every account the client knows about, keyed by cTID trader account ID.
|
|
@@ -90,6 +101,33 @@ class SessionStore:
|
|
|
90
101
|
def __init__(self) -> None:
|
|
91
102
|
self._accounts: dict[int, TrackedAccount] = {}
|
|
92
103
|
|
|
104
|
+
# Accounts whose authorization is being replaced by a token refresh.
|
|
105
|
+
# The server drops the old authorization as it issues the new pair, so
|
|
106
|
+
# for that stretch the account is genuinely unauthorized and answers
|
|
107
|
+
# nothing — a state no observer should mistake for a lost session.
|
|
108
|
+
self._refreshing: dict[int, anyio.Event] = {}
|
|
109
|
+
|
|
110
|
+
def begin_refresh(self, account_id: int) -> None:
|
|
111
|
+
"""Note that the account's authorization is being replaced."""
|
|
112
|
+
if account_id not in self._refreshing:
|
|
113
|
+
self._refreshing[account_id] = anyio.Event()
|
|
114
|
+
|
|
115
|
+
def end_refresh(self, account_id: int) -> None:
|
|
116
|
+
"""Note that it has been replaced, or that the attempt gave up."""
|
|
117
|
+
settled = self._refreshing.pop(account_id, None)
|
|
118
|
+
if settled is not None:
|
|
119
|
+
settled.set()
|
|
120
|
+
|
|
121
|
+
def is_refreshing(self, account_id: int) -> bool:
|
|
122
|
+
"""Whether a refresh is currently replacing the account's authorization."""
|
|
123
|
+
return account_id in self._refreshing
|
|
124
|
+
|
|
125
|
+
async def wait_for_refresh(self, account_id: int) -> None:
|
|
126
|
+
"""Wait until no refresh is replacing the account's authorization."""
|
|
127
|
+
settled = self._refreshing.get(account_id)
|
|
128
|
+
if settled is not None:
|
|
129
|
+
await settled.wait()
|
|
130
|
+
|
|
93
131
|
def account_ids(self) -> list[int]:
|
|
94
132
|
"""Every account held, oldest first."""
|
|
95
133
|
return list(self._accounts)
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/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
|
+
ProtoOATraderReq,
|
|
19
|
+
ProtoOATraderRes,
|
|
18
20
|
)
|
|
19
21
|
from ..enums import AuthTrigger
|
|
20
22
|
from ..events import EventPublisher, ReadyEvent
|
|
@@ -238,6 +240,43 @@ class AuthManager:
|
|
|
238
240
|
await self._restorer.restore(credentials.account_id)
|
|
239
241
|
await self._publisher.emit(ReadyEvent(account_id=credentials.account_id, trigger=trigger))
|
|
240
242
|
|
|
243
|
+
async def probe_session(self, account_id: int, timeout: float = 10.0) -> bool:
|
|
244
|
+
"""Whether the account still holds a live session on this link.
|
|
245
|
+
|
|
246
|
+
Asks the server for something only an authorized account is told. The
|
|
247
|
+
request carries no access token of its own, but the authorization behind
|
|
248
|
+
it does: while a rotation is in flight the server refuses everything for
|
|
249
|
+
the account, including this. That refusal names the token, and it is
|
|
250
|
+
raised rather than answered, because it says nothing about the session.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
account_id: The cTID trader account ID.
|
|
254
|
+
timeout: Request timeout in seconds.
|
|
255
|
+
|
|
256
|
+
Returns:
|
|
257
|
+
True if the server still serves the account on this link.
|
|
258
|
+
|
|
259
|
+
Raises:
|
|
260
|
+
APIError: If the refusal is about the token rather than the account.
|
|
261
|
+
CTraderConnectionTimeoutError: If the request times out.
|
|
262
|
+
CTraderConnectionClosedError: If the link is gone.
|
|
263
|
+
"""
|
|
264
|
+
request = ProtoOATraderReq(ctid_trader_account_id=account_id)
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
await self._protocol.request(request, ProtoOATraderRes, timeout=timeout)
|
|
268
|
+
except APIError as e:
|
|
269
|
+
logger.debug(
|
|
270
|
+
"Account %d was refused a trader lookup: %s (%s)",
|
|
271
|
+
account_id,
|
|
272
|
+
e.error_code,
|
|
273
|
+
e.description,
|
|
274
|
+
)
|
|
275
|
+
if e.is_token_failure():
|
|
276
|
+
raise
|
|
277
|
+
return False
|
|
278
|
+
return True
|
|
279
|
+
|
|
241
280
|
def remove_account(self, account_id: int) -> bool:
|
|
242
281
|
"""Remove an account from refresh monitoring.
|
|
243
282
|
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/events/router.py
RENAMED
|
@@ -27,7 +27,6 @@ from ..models import Trendbar
|
|
|
27
27
|
from ._execution import execution_event_from_proto
|
|
28
28
|
from .emitter import EventEmitter
|
|
29
29
|
from .types import (
|
|
30
|
-
AccountDisconnectEvent,
|
|
31
30
|
ClientDisconnectEvent,
|
|
32
31
|
DepthEvent,
|
|
33
32
|
DepthQuote,
|
|
@@ -52,8 +51,8 @@ logger = logging.getLogger(__name__)
|
|
|
52
51
|
class SessionRecovery(TypingProtocol):
|
|
53
52
|
"""Whatever knows how to restore an account session the server dropped."""
|
|
54
53
|
|
|
55
|
-
def handle_account_disconnect(self, account_id: int) -> None:
|
|
56
|
-
"""
|
|
54
|
+
async def handle_account_disconnect(self, account_id: int) -> None:
|
|
55
|
+
"""Check the reported disconnect, then publish and recover a real one."""
|
|
57
56
|
...
|
|
58
57
|
|
|
59
58
|
|
|
@@ -276,13 +275,15 @@ class EventRouter:
|
|
|
276
275
|
self,
|
|
277
276
|
proto: ProtoOAAccountDisconnectEvent,
|
|
278
277
|
) -> None:
|
|
279
|
-
"""
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
278
|
+
"""Hand a reported account disconnect to recovery, which publishes it.
|
|
279
|
+
|
|
280
|
+
The server reports a disconnect on every token rotation, for a session
|
|
281
|
+
it has not actually ended, so the report is checked before anyone hears
|
|
282
|
+
about it. Recovery does that checking and emits `AccountDisconnectEvent`
|
|
283
|
+
only for a drop that turns out to be real.
|
|
284
|
+
"""
|
|
285
|
+
logger.debug("Account %d reported as disconnected by server", proto.ctid_trader_account_id)
|
|
286
|
+
await self._recovery.handle_account_disconnect(proto.ctid_trader_account_id)
|
|
286
287
|
|
|
287
288
|
async def _handle_symbol_changed(
|
|
288
289
|
self,
|
{ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/events/types.py
RENAMED
|
@@ -238,11 +238,17 @@ class ClientDisconnectEvent:
|
|
|
238
238
|
class AccountDisconnectEvent:
|
|
239
239
|
"""Account disconnect event.
|
|
240
240
|
|
|
241
|
-
Emitted when
|
|
242
|
-
the underlying connection stays up
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
241
|
+
Emitted when an account's session has been terminated by the server while
|
|
242
|
+
the underlying connection stays up, and could not be re-established on the
|
|
243
|
+
spot. The client keeps re-authenticating on the existing connection with
|
|
244
|
+
backoff until it succeeds, then emits a ReadyEvent; no user action is
|
|
245
|
+
required to restore the session.
|
|
246
|
+
|
|
247
|
+
The server also reports a disconnect whenever the token behind an
|
|
248
|
+
authorization is rotated, for a session it has not actually ended. Those
|
|
249
|
+
reports are checked against the server and discarded, so they do not appear
|
|
250
|
+
here — every event published is a drop that outlived a re-authentication
|
|
251
|
+
attempt.
|
|
246
252
|
|
|
247
253
|
Attributes:
|
|
248
254
|
account_id: The cTID trader account ID.
|
|
@@ -108,6 +108,15 @@ class AccountAuthError(AuthenticationError):
|
|
|
108
108
|
message += f" - {description}"
|
|
109
109
|
super().__init__(message)
|
|
110
110
|
|
|
111
|
+
def is_already_authorized(self) -> bool:
|
|
112
|
+
"""Whether the account was rejected for holding a live session already.
|
|
113
|
+
|
|
114
|
+
The server refuses to authorize an account twice on one channel. A
|
|
115
|
+
caller that was trying to reach exactly that state has already reached
|
|
116
|
+
it, so this is a rejection worth reading as success.
|
|
117
|
+
"""
|
|
118
|
+
return self.error_code == ProtoOAErrorCode.ALREADY_LOGGED_IN.name
|
|
119
|
+
|
|
111
120
|
|
|
112
121
|
class TokenExpiredError(AuthenticationError):
|
|
113
122
|
"""Access token has expired."""
|