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.
Files changed (124) hide show
  1. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/.gitignore +4 -0
  2. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/PKG-INFO +1 -1
  3. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/api/events.md +19 -0
  4. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/pyproject.toml +1 -1
  5. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/_recovery.py +93 -8
  6. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/_refresh.py +7 -0
  7. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/_session.py +38 -0
  8. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/manager.py +39 -0
  9. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/events/router.py +11 -10
  10. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/events/types.py +11 -5
  11. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/exceptions.py +9 -0
  12. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/auth/test_token_lifecycle.py +172 -23
  13. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/events/test_routing.py +3 -2
  14. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/test_client.py +40 -1
  15. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/harness/factories.py +10 -0
  16. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/harness/recorder.py +1 -1
  17. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/uv.lock +1 -1
  18. ctrader_api_client-0.7.0/HARDENING.md +0 -251
  19. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/.claude/settings.local.json +0 -0
  20. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/.github/workflows/docs.yml +0 -0
  21. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/.pre-commit-config.yaml +0 -0
  22. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/.python-version +0 -0
  23. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/Justfile +0 -0
  24. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/LICENSE +0 -0
  25. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/README.md +0 -0
  26. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/api/accounts.md +0 -0
  27. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/api/client.md +0 -0
  28. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/api/enums.md +0 -0
  29. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/api/exceptions.md +0 -0
  30. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/api/market-data.md +0 -0
  31. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/api/models.md +0 -0
  32. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/api/symbols.md +0 -0
  33. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/api/trading.md +0 -0
  34. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/getting-started.md +0 -0
  35. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/docs/index.md +0 -0
  36. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/mkdocs.yml +0 -0
  37. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/protos/SOURCE +0 -0
  38. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/protos/VERSION +0 -0
  39. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/protos/update.sh +0 -0
  40. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/protos/vendor/OpenApiCommonMessages.proto +0 -0
  41. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
  42. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/protos/vendor/OpenApiMessages.proto +0 -0
  43. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/protos/vendor/OpenApiModelMessages.proto +0 -0
  44. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/scripts/fix_proto_imports.py +0 -0
  45. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/__init__.py +0 -0
  46. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/__init__.py +0 -0
  47. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/clock.py +0 -0
  48. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/conversions.py +0 -0
  49. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/messages.py +0 -0
  50. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
  51. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
  52. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
  53. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
  54. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
  55. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/serialization.py +0 -0
  56. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/api/__init__.py +0 -0
  57. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/api/_base.py +0 -0
  58. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/api/accounts.py +0 -0
  59. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/api/market_data.py +0 -0
  60. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/api/symbols.py +0 -0
  61. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/api/trading.py +0 -0
  62. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/__init__.py +0 -0
  63. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/credentials.py +0 -0
  64. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/policy.py +0 -0
  65. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/store.py +0 -0
  66. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/client.py +0 -0
  67. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/composition.py +0 -0
  68. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/config.py +0 -0
  69. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/connection/__init__.py +0 -0
  70. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/connection/heartbeat.py +0 -0
  71. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/connection/listener.py +0 -0
  72. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/connection/protocol.py +0 -0
  73. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/connection/supervisor.py +0 -0
  74. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/connection/transport.py +0 -0
  75. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/enums.py +0 -0
  76. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/events/__init__.py +0 -0
  77. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/events/_execution.py +0 -0
  78. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/events/emitter.py +0 -0
  79. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/events/publisher.py +0 -0
  80. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/__init__.py +0 -0
  81. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/_base.py +0 -0
  82. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/account.py +0 -0
  83. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/deal.py +0 -0
  84. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/market_data.py +0 -0
  85. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/order.py +0 -0
  86. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/position.py +0 -0
  87. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/requests.py +0 -0
  88. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/symbol.py +0 -0
  89. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/src/ctrader_api_client/py.typed +0 -0
  90. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/__init__.py +0 -0
  91. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/__init__.py +0 -0
  92. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/api/__init__.py +0 -0
  93. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/api/conftest.py +0 -0
  94. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/api/test_market_data.py +0 -0
  95. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/api/test_reference_data.py +0 -0
  96. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/api/test_subscription_restore.py +0 -0
  97. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/api/test_trading.py +0 -0
  98. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/auth/__init__.py +0 -0
  99. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/auth/conftest.py +0 -0
  100. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/auth/test_authentication.py +0 -0
  101. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/connection/__init__.py +0 -0
  102. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/connection/conftest.py +0 -0
  103. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/connection/test_event_dispatch.py +0 -0
  104. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/connection/test_framing.py +0 -0
  105. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/connection/test_heartbeat.py +0 -0
  106. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/connection/test_reconnection.py +0 -0
  107. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/connection/test_request_response.py +0 -0
  108. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/events/__init__.py +0 -0
  109. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/events/conftest.py +0 -0
  110. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/behavior/events/test_subscriptions.py +0 -0
  111. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/conftest.py +0 -0
  112. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/harness/__init__.py +0 -0
  113. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/harness/clock.py +0 -0
  114. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/harness/server.py +0 -0
  115. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/harness/signals.py +0 -0
  116. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/harness/stub_protocol.py +0 -0
  117. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/harness/wire.py +0 -0
  118. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/unit/__init__.py +0 -0
  119. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/unit/test_api_errors.py +0 -0
  120. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/unit/test_market_data_conversion.py +0 -0
  121. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/unit/test_request_conversion.py +0 -0
  122. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/unit/test_symbol_conversion.py +0 -0
  123. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/unit/test_trading_records.py +0 -0
  124. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.2}/tests/unit/test_wire_format.py +0 -0
@@ -12,6 +12,10 @@ wheels/
12
12
  # IDE-specific files
13
13
  .idea/
14
14
  .vscode/
15
+
15
16
  # Test artifacts
16
17
  .coverage
17
18
  .pytest_cache/
19
+
20
+ # Built documentation
21
+ site/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctrader-api-client
3
- Version: 0.7.0
3
+ Version: 0.7.2
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
@@ -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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrader-api-client"
3
- version = "0.7.0"
3
+ version = "0.7.2"
4
4
  description = "API Client to interact with the cTrader Open API spec"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -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
- """Handle a server-side account disconnect.
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
- Marks the account's session as no longer authorized and schedules
97
- recovery re-authentication on the existing connection. Idempotent while
98
- a recovery is already pending for the account.
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 not self._store.flag_for_recovery(account_id):
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._reauth_signal.set()
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",
@@ -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)
@@ -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)
@@ -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
 
@@ -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
- """Schedule re-authentication for the disconnected account."""
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
- """Convert ProtoOAAccountDisconnectEvent to AccountDisconnectEvent."""
280
- event = AccountDisconnectEvent(
281
- account_id=proto.ctid_trader_account_id,
282
- )
283
- logger.warning("Account %d disconnected by server", event.account_id)
284
- self._recovery.handle_account_disconnect(event.account_id)
285
- await self._emitter.emit(event)
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,
@@ -238,11 +238,17 @@ class ClientDisconnectEvent:
238
238
  class AccountDisconnectEvent:
239
239
  """Account disconnect event.
240
240
 
241
- Emitted when a specific account session is terminated by the server while
242
- the underlying connection stays up. The client handles recovery
243
- automatically: it re-authenticates the account on the existing connection
244
- with backoff until it succeeds, then emits a ReadyEvent. This event is
245
- informational; no user action is required to restore the session.
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."""