ctrader-api-client 0.7.1__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 (123) hide show
  1. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/.gitignore +4 -0
  2. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/PKG-INFO +1 -1
  3. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/docs/api/events.md +16 -4
  4. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/pyproject.toml +1 -1
  5. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/_recovery.py +39 -15
  6. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/_refresh.py +7 -0
  7. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/_session.py +38 -0
  8. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/manager.py +39 -0
  9. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/auth/test_token_lifecycle.py +117 -17
  10. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/test_client.py +8 -2
  11. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/harness/factories.py +10 -0
  12. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/uv.lock +1 -1
  13. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/.claude/settings.local.json +0 -0
  14. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/.github/workflows/docs.yml +0 -0
  15. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/.pre-commit-config.yaml +0 -0
  16. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/.python-version +0 -0
  17. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/Justfile +0 -0
  18. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/LICENSE +0 -0
  19. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/README.md +0 -0
  20. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/docs/api/accounts.md +0 -0
  21. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/docs/api/client.md +0 -0
  22. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/docs/api/enums.md +0 -0
  23. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/docs/api/exceptions.md +0 -0
  24. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/docs/api/market-data.md +0 -0
  25. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/docs/api/models.md +0 -0
  26. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/docs/api/symbols.md +0 -0
  27. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/docs/api/trading.md +0 -0
  28. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/docs/getting-started.md +0 -0
  29. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/docs/index.md +0 -0
  30. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/mkdocs.yml +0 -0
  31. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/protos/SOURCE +0 -0
  32. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/protos/VERSION +0 -0
  33. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/protos/update.sh +0 -0
  34. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/protos/vendor/OpenApiCommonMessages.proto +0 -0
  35. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
  36. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/protos/vendor/OpenApiMessages.proto +0 -0
  37. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/protos/vendor/OpenApiModelMessages.proto +0 -0
  38. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/scripts/fix_proto_imports.py +0 -0
  39. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/__init__.py +0 -0
  40. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/__init__.py +0 -0
  41. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/clock.py +0 -0
  42. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/conversions.py +0 -0
  43. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/messages.py +0 -0
  44. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
  45. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
  46. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
  47. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
  48. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
  49. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/_internal/serialization.py +0 -0
  50. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/api/__init__.py +0 -0
  51. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/api/_base.py +0 -0
  52. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/api/accounts.py +0 -0
  53. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/api/market_data.py +0 -0
  54. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/api/symbols.py +0 -0
  55. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/api/trading.py +0 -0
  56. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/__init__.py +0 -0
  57. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/credentials.py +0 -0
  58. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/policy.py +0 -0
  59. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/auth/store.py +0 -0
  60. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/client.py +0 -0
  61. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/composition.py +0 -0
  62. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/config.py +0 -0
  63. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/connection/__init__.py +0 -0
  64. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/connection/heartbeat.py +0 -0
  65. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/connection/listener.py +0 -0
  66. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/connection/protocol.py +0 -0
  67. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/connection/supervisor.py +0 -0
  68. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/connection/transport.py +0 -0
  69. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/enums.py +0 -0
  70. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/events/__init__.py +0 -0
  71. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/events/_execution.py +0 -0
  72. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/events/emitter.py +0 -0
  73. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/events/publisher.py +0 -0
  74. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/events/router.py +0 -0
  75. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/events/types.py +0 -0
  76. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/exceptions.py +0 -0
  77. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/__init__.py +0 -0
  78. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/_base.py +0 -0
  79. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/account.py +0 -0
  80. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/deal.py +0 -0
  81. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/market_data.py +0 -0
  82. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/order.py +0 -0
  83. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/position.py +0 -0
  84. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/requests.py +0 -0
  85. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/models/symbol.py +0 -0
  86. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/src/ctrader_api_client/py.typed +0 -0
  87. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/__init__.py +0 -0
  88. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/__init__.py +0 -0
  89. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/api/__init__.py +0 -0
  90. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/api/conftest.py +0 -0
  91. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/api/test_market_data.py +0 -0
  92. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/api/test_reference_data.py +0 -0
  93. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/api/test_subscription_restore.py +0 -0
  94. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/api/test_trading.py +0 -0
  95. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/auth/__init__.py +0 -0
  96. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/auth/conftest.py +0 -0
  97. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/auth/test_authentication.py +0 -0
  98. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/connection/__init__.py +0 -0
  99. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/connection/conftest.py +0 -0
  100. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/connection/test_event_dispatch.py +0 -0
  101. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/connection/test_framing.py +0 -0
  102. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/connection/test_heartbeat.py +0 -0
  103. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/connection/test_reconnection.py +0 -0
  104. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/connection/test_request_response.py +0 -0
  105. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/events/__init__.py +0 -0
  106. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/events/conftest.py +0 -0
  107. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/events/test_routing.py +0 -0
  108. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/behavior/events/test_subscriptions.py +0 -0
  109. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/conftest.py +0 -0
  110. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/harness/__init__.py +0 -0
  111. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/harness/clock.py +0 -0
  112. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/harness/recorder.py +0 -0
  113. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/harness/server.py +0 -0
  114. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/harness/signals.py +0 -0
  115. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/harness/stub_protocol.py +0 -0
  116. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/harness/wire.py +0 -0
  117. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/unit/__init__.py +0 -0
  118. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/unit/test_api_errors.py +0 -0
  119. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/unit/test_market_data_conversion.py +0 -0
  120. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/unit/test_request_conversion.py +0 -0
  121. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/unit/test_symbol_conversion.py +0 -0
  122. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.2}/tests/unit/test_trading_records.py +0 -0
  123. {ctrader_api_client-0.7.1 → 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.1
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
@@ -161,10 +161,22 @@ drop. Check current authorization with `client.is_account_authorized(account_id)
161
161
 
162
162
  A disconnect the server reports is checked before it is published. Rotating an
163
163
  access token makes the server report one for a session it has not ended, so the
164
- client re-authenticates to find out which it is: an account that still holds its
165
- session is refused as already authorized, and nothing is published. What reaches
166
- a handler is therefore always a session that was really gone at the moment it
167
- was checked.
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.
168
180
 
169
181
  ::: ctrader_api_client.events.TokenInvalidatedEvent
170
182
  options:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrader-api-client"
3
- version = "0.7.1"
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 = [
@@ -104,14 +104,32 @@ class SessionRecovery:
104
104
  unauthorized and publish a drop that never occurred, so the report is
105
105
  checked against the server before anything acts on it.
106
106
 
107
- Re-authenticating is the check: a session that is already live is
108
- refused with `ALREADY_LOGGED_IN`, which says the report was spurious and
109
- there is nothing to tell anyone. Any other answer means the session
110
- really did end, and only then is the drop published and recovery armed.
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.
114
+
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.
111
121
 
112
122
  Args:
113
123
  account_id: The cTID trader account ID reported as disconnected.
114
124
  """
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
+
115
133
  credentials = self._store.credentials(account_id)
116
134
  if credentials is None or not self._store.is_authorized(account_id):
117
135
  return
@@ -121,30 +139,36 @@ class SessionRecovery:
121
139
 
122
140
  self._verifying.add(account_id)
123
141
  try:
124
- await self._authenticator.establish(credentials, AuthTrigger.ACCOUNT_REAUTH)
125
- except AccountAuthError as e:
126
- if e.is_already_authorized():
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:
127
153
  logger.debug(
128
154
  "Account %d reported as disconnected still holds its session; ignoring",
129
155
  account_id,
130
156
  )
131
157
  return
132
- await self._report_dropped(account_id, e)
133
- except Exception as e:
134
- await self._report_dropped(account_id, e)
158
+
159
+ await self._report_dropped(account_id)
135
160
  finally:
136
161
  self._verifying.discard(account_id)
137
162
 
138
- async def _report_dropped(self, account_id: int, error: Exception) -> None:
163
+ async def _report_dropped(self, account_id: int) -> None:
139
164
  """Publish a confirmed drop and arm recovery for it.
140
165
 
141
- Reached once the server has refused to re-authorize the account, so the
142
- session is genuinely gone and could not be re-established on the spot.
166
+ Reached once the server has stopped serving the account on this link, so
167
+ the session is genuinely gone.
143
168
  """
144
169
  logger.warning(
145
- "Account %d disconnected by server; scheduling re-authentication: %s",
170
+ "Account %d disconnected by server; scheduling re-authentication",
146
171
  account_id,
147
- error,
148
172
  )
149
173
  self._store.flag_for_recovery(account_id)
150
174
  self._reauth_signal.set()
@@ -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
 
@@ -4,14 +4,19 @@ from __future__ import annotations
4
4
 
5
5
  from collections.abc import Callable
6
6
 
7
+ import anyio
7
8
  import betterproto
8
9
  import pytest
9
10
 
10
- from ctrader_api_client._internal.proto import ProtoOAAccountAuthReq, ProtoOARefreshTokenReq
11
+ from ctrader_api_client._internal.proto import (
12
+ ProtoOAAccountAuthReq,
13
+ ProtoOARefreshTokenReq,
14
+ ProtoOATraderReq,
15
+ )
11
16
  from ctrader_api_client.auth import AuthManager, ReauthPolicy, SessionRecovery, SessionStore
12
17
  from ctrader_api_client.enums import AuthTrigger
13
18
  from ctrader_api_client.events import AccountDisconnectEvent, ReadyEvent, TokenRefreshFailedEvent
14
- from ctrader_api_client.exceptions import APIError, TokenExpiredError
19
+ from ctrader_api_client.exceptions import APIError, CTraderConnectionTimeoutError, TokenExpiredError
15
20
 
16
21
  from ...harness import ManualClock, RecordingPublisher, RecordingStore, StubProtocol, factories
17
22
  from .conftest import Monitors
@@ -304,6 +309,7 @@ async def test_an_account_the_server_confirms_is_gone_stops_being_authorized(
304
309
  ProtoOAAccountAuthReq,
305
310
  [factories.account_auth_res(), APIError(error_code="TEMPORARY")],
306
311
  )
312
+ protocol.respond(ProtoOATraderReq, APIError(error_code="ACCOUNT_NOT_AUTHORIZED"))
307
313
  await auth.authenticate_trader(factories.credentials(expires_in=PLENTY_OF_TIME))
308
314
  await clock.wait_for_sleepers(SLEEPERS_WHEN_STARTED)
309
315
 
@@ -325,14 +331,11 @@ async def test_a_session_the_server_still_holds_is_left_alone(
325
331
  Regression guard: taking that report at face value marked a working account
326
332
  unauthorized, published a drop that never happened, and left recovery
327
333
  retrying an account the server would only ever refuse as already logged in.
334
+ Checking it by re-authorizing was no better, because the rotation that
335
+ prompts the report is what makes that check answer wrongly.
328
336
  """
329
- protocol.respond_in_sequence(
330
- ProtoOAAccountAuthReq,
331
- [
332
- factories.account_auth_res(),
333
- APIError(error_code="ALREADY_LOGGED_IN", description="Trading account is already authorized"),
334
- ],
335
- )
337
+ protocol.respond(ProtoOAAccountAuthReq, factories.account_auth_res())
338
+ protocol.respond(ProtoOATraderReq, factories.trader_res())
336
339
  await auth.authenticate_trader(factories.credentials(expires_in=PLENTY_OF_TIME))
337
340
  await clock.wait_for_sleepers(SLEEPERS_WHEN_STARTED)
338
341
 
@@ -341,6 +344,98 @@ async def test_a_session_the_server_still_holds_is_left_alone(
341
344
  assert auth.is_account_authorized(factories.ACCOUNT_ID)
342
345
  assert publisher.of(AccountDisconnectEvent) == []
343
346
  assert publisher.of(ReadyEvent) == [ReadyEvent(factories.ACCOUNT_ID, AuthTrigger.INITIAL)]
347
+ # Nothing re-authorized the account, so nothing could have disturbed the
348
+ # session the report was wrong about.
349
+ assert len(protocol.sent_of(ProtoOAAccountAuthReq)) == 1
350
+
351
+
352
+ async def test_a_check_that_cannot_be_answered_leaves_the_account_alone(
353
+ auth: AuthManager,
354
+ monitors: Monitors,
355
+ protocol: StubProtocol,
356
+ clock: ManualClock,
357
+ publisher: RecordingPublisher,
358
+ ) -> None:
359
+ """Failing to ask is not the same as being told the session is gone.
360
+
361
+ Regression guard: anything that stopped the check from completing used to
362
+ count as confirmation, so a report that could not be checked took a working
363
+ account out of service.
364
+ """
365
+ protocol.respond(ProtoOAAccountAuthReq, factories.account_auth_res())
366
+ protocol.respond(ProtoOATraderReq, CTraderConnectionTimeoutError(10.0, "request"))
367
+ await auth.authenticate_trader(factories.credentials(expires_in=PLENTY_OF_TIME))
368
+ await clock.wait_for_sleepers(SLEEPERS_WHEN_STARTED)
369
+
370
+ await monitors.recovery.handle_account_disconnect(factories.ACCOUNT_ID)
371
+
372
+ assert auth.is_account_authorized(factories.ACCOUNT_ID)
373
+ assert publisher.of(AccountDisconnectEvent) == []
374
+
375
+
376
+ async def test_a_disconnect_reported_while_the_token_rotates_is_not_a_drop(
377
+ auth: AuthManager,
378
+ monitors: Monitors,
379
+ protocol: StubProtocol,
380
+ clock: ManualClock,
381
+ publisher: RecordingPublisher,
382
+ sessions: SessionStore,
383
+ ) -> None:
384
+ """The window a refresh opens is not a session anyone lost.
385
+
386
+ Regression guard: the server reports a disconnect for the authorization a
387
+ rotation replaces, and while the replacement is in flight the account is
388
+ genuinely unauthorized — it refuses everything with INVALID_REQUEST. Asking
389
+ it anything in that window and believing the answer published a drop for a
390
+ session that was serving requests a moment later.
391
+ """
392
+ protocol.respond(ProtoOAAccountAuthReq, factories.account_auth_res())
393
+ protocol.respond(ProtoOATraderReq, APIError(error_code="INVALID_REQUEST"))
394
+ await auth.authenticate_trader(factories.credentials(expires_in=PLENTY_OF_TIME))
395
+ await clock.wait_for_sleepers(SLEEPERS_WHEN_STARTED)
396
+ sessions.begin_refresh(factories.ACCOUNT_ID)
397
+
398
+ async with anyio.create_task_group() as task_group:
399
+ task_group.start_soon(monitors.recovery.handle_account_disconnect, factories.ACCOUNT_ID)
400
+ await anyio.sleep(0.01)
401
+ # Nothing has been decided while the rotation is in flight.
402
+ assert publisher.of(AccountDisconnectEvent) == []
403
+ assert not protocol.sent_of(ProtoOATraderReq)
404
+
405
+ protocol.respond(ProtoOATraderReq, factories.trader_res())
406
+ sessions.end_refresh(factories.ACCOUNT_ID)
407
+
408
+ assert auth.is_account_authorized(factories.ACCOUNT_ID)
409
+ assert publisher.of(AccountDisconnectEvent) == []
410
+ assert len(protocol.sent_of(ProtoOATraderReq)) == 1
411
+
412
+
413
+ @pytest.mark.parametrize("error_code", ["OA_AUTH_TOKEN_EXPIRED", "CH_ACCESS_TOKEN_INVALID"])
414
+ async def test_a_check_refused_over_the_token_leaves_the_account_alone(
415
+ error_code: str,
416
+ auth: AuthManager,
417
+ monitors: Monitors,
418
+ protocol: StubProtocol,
419
+ clock: ManualClock,
420
+ publisher: RecordingPublisher,
421
+ ) -> None:
422
+ """A refusal naming the token says nothing about the session behind it.
423
+
424
+ Regression guard: the server refuses everything for an account while a
425
+ rotation is in flight, and the disconnect it reports for that same rotation
426
+ arrives inside the window. Reading the refusal as an answer published a drop
427
+ for a session that was still being served.
428
+ """
429
+ protocol.respond(ProtoOAAccountAuthReq, factories.account_auth_res())
430
+ protocol.respond(ProtoOATraderReq, APIError(error_code=error_code))
431
+ await auth.authenticate_trader(factories.credentials(expires_in=PLENTY_OF_TIME))
432
+ await clock.wait_for_sleepers(SLEEPERS_WHEN_STARTED)
433
+
434
+ await monitors.recovery.handle_account_disconnect(factories.ACCOUNT_ID)
435
+
436
+ assert auth.is_account_authorized(factories.ACCOUNT_ID)
437
+ assert publisher.of(AccountDisconnectEvent) == []
438
+ assert len(protocol.sent_of(ProtoOAAccountAuthReq)) == 1
344
439
 
345
440
 
346
441
  async def test_a_disconnected_account_is_re_authenticated(
@@ -351,6 +446,7 @@ async def test_a_disconnected_account_is_re_authenticated(
351
446
  publisher: RecordingPublisher,
352
447
  ) -> None:
353
448
  await start_with_account(auth, protocol, clock, expires_in=PLENTY_OF_TIME)
449
+ protocol.respond(ProtoOATraderReq, APIError(error_code="ACCOUNT_NOT_AUTHORIZED"))
354
450
 
355
451
  await monitors.recovery.handle_account_disconnect(factories.ACCOUNT_ID)
356
452
  await publisher.wait_for_type(ReadyEvent, count=2)
@@ -370,12 +466,13 @@ async def test_recovery_keeps_trying_after_a_failure(
370
466
  ProtoOAAccountAuthReq,
371
467
  [
372
468
  factories.account_auth_res(),
373
- # The check that confirms the drop, then the first retry after it.
374
- APIError(error_code="TEMPORARY"),
469
+ # The first recovery attempt after the drop is confirmed, then the
470
+ # retry that succeeds.
375
471
  APIError(error_code="TEMPORARY"),
376
472
  factories.account_auth_res(),
377
473
  ],
378
474
  )
475
+ protocol.respond(ProtoOATraderReq, APIError(error_code="ACCOUNT_NOT_AUTHORIZED"))
379
476
  await auth.authenticate_trader(factories.credentials(expires_in=PLENTY_OF_TIME))
380
477
  await clock.wait_for_sleepers(SLEEPERS_WHEN_STARTED)
381
478
 
@@ -393,6 +490,7 @@ async def test_an_account_dropped_during_another_backoff_is_recovered_at_once(
393
490
  make_monitors: Callable[..., Monitors],
394
491
  protocol: StubProtocol,
395
492
  clock: ManualClock,
493
+ publisher: RecordingPublisher,
396
494
  ) -> None:
397
495
  """Backoff is per account, so a healthy account never queues behind a sick one."""
398
496
  stubborn = 111
@@ -411,12 +509,16 @@ async def test_an_account_dropped_during_another_backoff_is_recovered_at_once(
411
509
  return factories.account_auth_res()
412
510
 
413
511
  protocol.respond_with(ProtoOAAccountAuthReq, answer)
512
+ # Both reports are real: the server no longer serves either account.
513
+ protocol.respond(ProtoOATraderReq, APIError(error_code="ACCOUNT_NOT_AUTHORIZED"))
414
514
 
415
515
  await monitors.recovery.handle_account_disconnect(stubborn)
416
516
  # Its failed attempt parks recovery on a ten second backoff.
417
517
  await clock.wait_for_sleepers(SLEEPERS_WHEN_STARTED + 1)
418
518
 
419
519
  await monitors.recovery.handle_account_disconnect(healthy)
520
+ # Recovery re-authorizes it without waiting out the stubborn account's backoff.
521
+ await publisher.wait_for_type(ReadyEvent, count=3)
420
522
 
421
523
  assert auth.is_account_authorized(healthy)
422
524
  assert not auth.is_account_authorized(stubborn)
@@ -450,16 +552,14 @@ async def test_a_report_for_an_account_already_recovering_is_not_checked_again(
450
552
  indistinguishable from a second check of the same report.
451
553
  """
452
554
  recovery = SessionRecovery(store=sessions, authenticator=auth, publisher=publisher, clock=clock)
453
- protocol.respond_in_sequence(
454
- ProtoOAAccountAuthReq,
455
- [factories.account_auth_res(), APIError(error_code="TEMPORARY")],
456
- )
555
+ protocol.respond(ProtoOAAccountAuthReq, factories.account_auth_res())
556
+ protocol.respond(ProtoOATraderReq, APIError(error_code="ACCOUNT_NOT_AUTHORIZED"))
457
557
  await auth.authenticate_trader(factories.credentials(expires_in=PLENTY_OF_TIME))
458
558
 
459
559
  await recovery.handle_account_disconnect(factories.ACCOUNT_ID)
460
- checked = len(protocol.sent_of(ProtoOAAccountAuthReq))
560
+ checked = len(protocol.sent_of(ProtoOATraderReq))
461
561
  await recovery.handle_account_disconnect(factories.ACCOUNT_ID)
462
562
  await recovery.handle_account_disconnect(factories.ACCOUNT_ID)
463
563
 
464
- assert len(protocol.sent_of(ProtoOAAccountAuthReq)) == checked
564
+ assert len(protocol.sent_of(ProtoOATraderReq)) == checked
465
565
  assert publisher.of(AccountDisconnectEvent) == [AccountDisconnectEvent(factories.ACCOUNT_ID)]
@@ -14,6 +14,7 @@ from ctrader_api_client._internal.proto import (
14
14
  ProtoOAAccountDisconnectEvent,
15
15
  ProtoOAApplicationAuthReq,
16
16
  ProtoOARefreshTokenReq,
17
+ ProtoOATraderReq,
17
18
  )
18
19
  from ctrader_api_client.auth import AccountCredentials
19
20
  from ctrader_api_client.events import (
@@ -383,6 +384,8 @@ async def test_an_account_the_server_drops_is_re_authenticated(
383
384
  await authenticate(client, server)
384
385
  ready: Recorder[ReadyEvent] = Recorder()
385
386
  client.register_handler(ReadyEvent, ready)
387
+ # The server stands behind the report: it will not answer for the account.
388
+ server.on(ProtoOATraderReq, rejecting)
386
389
 
387
390
  await server.push(ProtoOAAccountDisconnectEvent(ctid_trader_account_id=factories.ACCOUNT_ID))
388
391
  await ready.wait_for(1)
@@ -398,6 +401,7 @@ async def test_a_dropped_account_stays_unauthorized_until_recovery_succeeds(
398
401
  await authenticate(client, server)
399
402
  dropped: Recorder[AccountDisconnectEvent] = Recorder()
400
403
  client.register_handler(AccountDisconnectEvent, dropped)
404
+ server.on(ProtoOATraderReq, rejecting)
401
405
  server.on(ProtoOAAccountAuthReq, rejecting)
402
406
 
403
407
  await server.push(ProtoOAAccountDisconnectEvent(ctid_trader_account_id=factories.ACCOUNT_ID))
@@ -419,14 +423,16 @@ async def test_a_disconnect_the_server_does_not_stand_behind_is_not_published(
419
423
  ready: Recorder[ReadyEvent] = Recorder()
420
424
  client.register_handler(AccountDisconnectEvent, dropped)
421
425
  client.register_handler(ReadyEvent, ready)
422
- server.on(ProtoOAAccountAuthReq, already_logged_in)
426
+ server.respond(ProtoOATraderReq, factories.trader_res())
423
427
 
424
428
  await server.push(ProtoOAAccountDisconnectEvent(ctid_trader_account_id=factories.ACCOUNT_ID))
425
- await server.wait_for_request(ProtoOAAccountAuthReq, count=2)
429
+ await server.wait_for_request(ProtoOATraderReq)
426
430
 
427
431
  assert dropped.count == 0
428
432
  assert ready.count == 0
429
433
  assert client.is_account_authorized(factories.ACCOUNT_ID) is True
434
+ # The account was never re-authorized, only asked after.
435
+ assert len(server.requests_of(ProtoOAAccountAuthReq)) == 1
430
436
 
431
437
 
432
438
  # -----------------------------------------------------------------------------
@@ -16,6 +16,8 @@ from ctrader_api_client._internal.proto import (
16
16
  ProtoOAGetAccountListByAccessTokenRes,
17
17
  ProtoOARefreshTokenRes,
18
18
  ProtoOASpotEvent,
19
+ ProtoOATrader,
20
+ ProtoOATraderRes,
19
21
  ProtoOATrendbar,
20
22
  )
21
23
  from ctrader_api_client.auth import AccountCredentials
@@ -57,6 +59,14 @@ def account_auth_res(account_id: int = ACCOUNT_ID) -> ProtoOAAccountAuthRes:
57
59
  return ProtoOAAccountAuthRes(ctid_trader_account_id=account_id)
58
60
 
59
61
 
62
+ def trader_res(account_id: int = ACCOUNT_ID) -> ProtoOATraderRes:
63
+ """A successful trader lookup, which only an authorized account is given."""
64
+ return ProtoOATraderRes(
65
+ ctid_trader_account_id=account_id,
66
+ trader=ProtoOATrader(ctid_trader_account_id=account_id),
67
+ )
68
+
69
+
60
70
  def ctid_account(
61
71
  account_id: int = ACCOUNT_ID,
62
72
  trader_login: int = TRADER_LOGIN,
@@ -270,7 +270,7 @@ wheels = [
270
270
 
271
271
  [[package]]
272
272
  name = "ctrader-api-client"
273
- version = "0.7.1"
273
+ version = "0.7.2"
274
274
  source = { editable = "." }
275
275
  dependencies = [
276
276
  { name = "anyio" },