ctrader-api-client 0.7.2__tar.gz → 0.7.3__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.2 → ctrader_api_client-0.7.3}/PKG-INFO +1 -1
  2. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/docs/api/events.md +8 -0
  3. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/pyproject.toml +2 -1
  4. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/_recovery.py +89 -3
  5. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/connection/protocol.py +68 -23
  6. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/connection/transport.py +1 -1
  7. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/events/types.py +7 -1
  8. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/connection/test_reconnection.py +97 -0
  9. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/test_client.py +78 -1
  10. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/uv.lock +1 -1
  11. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/.claude/settings.local.json +0 -0
  12. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/.github/workflows/docs.yml +0 -0
  13. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/.gitignore +0 -0
  14. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/.pre-commit-config.yaml +0 -0
  15. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/.python-version +0 -0
  16. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/Justfile +0 -0
  17. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/LICENSE +0 -0
  18. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/README.md +0 -0
  19. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/docs/api/accounts.md +0 -0
  20. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/docs/api/client.md +0 -0
  21. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/docs/api/enums.md +0 -0
  22. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/docs/api/exceptions.md +0 -0
  23. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/docs/api/market-data.md +0 -0
  24. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/docs/api/models.md +0 -0
  25. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/docs/api/symbols.md +0 -0
  26. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/docs/api/trading.md +0 -0
  27. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/docs/getting-started.md +0 -0
  28. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/docs/index.md +0 -0
  29. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/mkdocs.yml +0 -0
  30. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/protos/SOURCE +0 -0
  31. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/protos/VERSION +0 -0
  32. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/protos/update.sh +0 -0
  33. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/protos/vendor/OpenApiCommonMessages.proto +0 -0
  34. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
  35. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/protos/vendor/OpenApiMessages.proto +0 -0
  36. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/protos/vendor/OpenApiModelMessages.proto +0 -0
  37. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/scripts/fix_proto_imports.py +0 -0
  38. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/__init__.py +0 -0
  39. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/__init__.py +0 -0
  40. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/clock.py +0 -0
  41. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/conversions.py +0 -0
  42. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/messages.py +0 -0
  43. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
  44. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
  45. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
  46. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
  47. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
  48. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/serialization.py +0 -0
  49. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/api/__init__.py +0 -0
  50. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/api/_base.py +0 -0
  51. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/api/accounts.py +0 -0
  52. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/api/market_data.py +0 -0
  53. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/api/symbols.py +0 -0
  54. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/api/trading.py +0 -0
  55. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/__init__.py +0 -0
  56. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/_refresh.py +0 -0
  57. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/_session.py +0 -0
  58. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/credentials.py +0 -0
  59. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/manager.py +0 -0
  60. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/policy.py +0 -0
  61. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/store.py +0 -0
  62. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/client.py +0 -0
  63. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/composition.py +0 -0
  64. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/config.py +0 -0
  65. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/connection/__init__.py +0 -0
  66. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/connection/heartbeat.py +0 -0
  67. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/connection/listener.py +0 -0
  68. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/connection/supervisor.py +0 -0
  69. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/enums.py +0 -0
  70. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/events/__init__.py +0 -0
  71. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/events/_execution.py +0 -0
  72. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/events/emitter.py +0 -0
  73. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/events/publisher.py +0 -0
  74. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/events/router.py +0 -0
  75. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/exceptions.py +0 -0
  76. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/__init__.py +0 -0
  77. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/_base.py +0 -0
  78. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/account.py +0 -0
  79. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/deal.py +0 -0
  80. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/market_data.py +0 -0
  81. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/order.py +0 -0
  82. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/position.py +0 -0
  83. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/requests.py +0 -0
  84. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/symbol.py +0 -0
  85. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/src/ctrader_api_client/py.typed +0 -0
  86. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/__init__.py +0 -0
  87. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/__init__.py +0 -0
  88. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/api/__init__.py +0 -0
  89. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/api/conftest.py +0 -0
  90. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/api/test_market_data.py +0 -0
  91. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/api/test_reference_data.py +0 -0
  92. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/api/test_subscription_restore.py +0 -0
  93. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/api/test_trading.py +0 -0
  94. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/auth/__init__.py +0 -0
  95. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/auth/conftest.py +0 -0
  96. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/auth/test_authentication.py +0 -0
  97. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/auth/test_token_lifecycle.py +0 -0
  98. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/connection/__init__.py +0 -0
  99. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/connection/conftest.py +0 -0
  100. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/connection/test_event_dispatch.py +0 -0
  101. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/connection/test_framing.py +0 -0
  102. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/connection/test_heartbeat.py +0 -0
  103. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/connection/test_request_response.py +0 -0
  104. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/events/__init__.py +0 -0
  105. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/events/conftest.py +0 -0
  106. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/events/test_routing.py +0 -0
  107. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/behavior/events/test_subscriptions.py +0 -0
  108. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/conftest.py +0 -0
  109. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/harness/__init__.py +0 -0
  110. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/harness/clock.py +0 -0
  111. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/harness/factories.py +0 -0
  112. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/harness/recorder.py +0 -0
  113. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/harness/server.py +0 -0
  114. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/harness/signals.py +0 -0
  115. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/harness/stub_protocol.py +0 -0
  116. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/harness/wire.py +0 -0
  117. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/unit/__init__.py +0 -0
  118. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/unit/test_api_errors.py +0 -0
  119. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/unit/test_market_data_conversion.py +0 -0
  120. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/unit/test_request_conversion.py +0 -0
  121. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/unit/test_symbol_conversion.py +0 -0
  122. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/unit/test_trading_records.py +0 -0
  123. {ctrader_api_client-0.7.2 → ctrader_api_client-0.7.3}/tests/unit/test_wire_format.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctrader-api-client
3
- Version: 0.7.2
3
+ Version: 0.7.3
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
@@ -146,6 +146,14 @@ async def on_reconnected(event: ReconnectedEvent):
146
146
  print(f"Failed accounts: {event.failed_accounts}")
147
147
  ```
148
148
 
149
+ An `app_auth_restored=False` is not the end of the story. The server can refuse
150
+ application authentication for transient reasons of its own, so the client keeps
151
+ retrying with backoff and announces each account it restores afterwards with a
152
+ `ReadyEvent`. Treat this event as a signal that the link is back but degraded —
153
+ stop acting on the session until the accounts are announced ready — rather than
154
+ as a permanent failure. Check the current state with
155
+ `client.auth.is_app_authenticated`.
156
+
149
157
  ::: ctrader_api_client.events.ClientDisconnectEvent
150
158
  options:
151
159
  show_source: false
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrader-api-client"
3
- version = "0.7.2"
3
+ version = "0.7.3"
4
4
  description = "API Client to interact with the cTrader Open API spec"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -79,6 +79,7 @@ select = [
79
79
  "RUF022", # Unsorted __all__ blocks
80
80
  "FURB157", # Check unnecessary casts in Decimal constructors
81
81
  "RUF032", # Check for Decimal calls passing a float literal
82
+ "G004", # Check for all f strings in logging calls
82
83
  ]
83
84
 
84
85
  [tool.ruff.lint.per-file-ignores]
@@ -60,6 +60,14 @@ class SessionRecovery:
60
60
  # repeated report does not start a second check against the server.
61
61
  self._verifying: set[int] = set()
62
62
 
63
+ # A reconnect whose application re-authentication failed, left for the
64
+ # recovery loop to keep retrying. Nothing can be authorized on a link
65
+ # the application itself is not authenticated on, so this is settled
66
+ # before any account is attempted.
67
+ self._app_auth_pending = False
68
+ self._app_auth_attempts = 0
69
+ self._app_auth_next_at = 0.0
70
+
63
71
  self._task_scope: anyio.CancelScope | None = None
64
72
  self._running = False
65
73
 
@@ -85,6 +93,7 @@ class SessionRecovery:
85
93
  finally:
86
94
  self._running = False
87
95
  self._task_scope = None
96
+ self._app_auth_pending = False
88
97
  self._store.invalidate_all()
89
98
  logger.debug("Session recovery monitor stopped")
90
99
 
@@ -181,6 +190,11 @@ class SessionRecovery:
181
190
  credentials are kept, so the sessions can be re-established once the
182
191
  link is back.
183
192
  """
193
+ # Any application re-authentication still being retried belonged to the
194
+ # link that just died. The reconnection attempts it again from scratch
195
+ # and re-arms this if it has to, so carrying the old state forward would
196
+ # only make the next backoff start deeper than it should.
197
+ self._app_auth_pending = False
184
198
  self._store.invalidate_all()
185
199
 
186
200
  async def on_connection_restored(self) -> None:
@@ -190,6 +204,11 @@ class SessionRecovery:
190
204
  link is already back, and a caller can only act on a failure it is told
191
205
  about. Without application authentication nothing else can be
192
206
  attempted, so that failure is reported on its own.
207
+
208
+ Only the first attempt is made here. This runs inside the protocol's
209
+ reconnection task, which cannot detect a further drop while it is
210
+ blocked, so a failure is handed to the recovery loop to keep retrying in
211
+ the background rather than waited out inline.
193
212
  """
194
213
  logger.debug("Connection restored, re-authenticating...")
195
214
 
@@ -197,7 +216,8 @@ class SessionRecovery:
197
216
  await self._authenticator.authenticate_app()
198
217
  logger.debug("App re-authenticated successfully")
199
218
  except Exception as e:
200
- logger.error("Failed to re-authenticate app after reconnect: %s", e)
219
+ logger.error("Failed to re-authenticate app after reconnect, will retry: %s", e)
220
+ self._arm_app_auth_recovery()
201
221
  await self._publisher.emit(
202
222
  ReconnectedEvent(
203
223
  app_auth_restored=False,
@@ -228,15 +248,66 @@ class SessionRecovery:
228
248
  )
229
249
  )
230
250
 
251
+ def _backoff_delay(self, attempts: int) -> float:
252
+ """How long to wait before attempt number `attempts`, per the policy."""
253
+ return min(self._policy.min_wait * 2 ** (attempts - 1), self._policy.max_wait)
254
+
231
255
  def _defer_recovery(self, account_id: int, state: AwaitingRecovery) -> None:
232
256
  """Back off before the next recovery attempt for a single account."""
233
257
  attempts = state.attempts + 1
234
- delay = min(self._policy.min_wait * 2 ** (attempts - 1), self._policy.max_wait)
235
258
  self._store.reschedule_recovery(
236
259
  account_id,
237
- AwaitingRecovery(attempts=attempts, next_attempt_at=self._clock.now() + delay),
260
+ AwaitingRecovery(
261
+ attempts=attempts,
262
+ next_attempt_at=self._clock.now() + self._backoff_delay(attempts),
263
+ ),
238
264
  )
239
265
 
266
+ def _arm_app_auth_recovery(self) -> None:
267
+ """Hand a failed application re-authentication to the recovery loop.
268
+
269
+ Every account is flagged alongside it: their sessions died with the
270
+ link, and the re-authentication that would have restored them is the
271
+ very thing that failed, so they are recovered by the same loop once the
272
+ application is authenticated again.
273
+ """
274
+ self._app_auth_pending = True
275
+ self._app_auth_attempts = 0
276
+ self._defer_app_auth()
277
+
278
+ for credentials in self._store.all_credentials():
279
+ self._store.flag_for_recovery(credentials.account_id)
280
+
281
+ self._reauth_signal.set()
282
+
283
+ def _defer_app_auth(self) -> None:
284
+ """Back off before the next application re-authentication attempt."""
285
+ self._app_auth_attempts += 1
286
+ self._app_auth_next_at = self._clock.now() + self._backoff_delay(self._app_auth_attempts)
287
+
288
+ async def _retry_app_auth(self) -> bool:
289
+ """Attempt the deferred application re-authentication once.
290
+
291
+ Returns:
292
+ True if the application is authenticated again, False if the
293
+ attempt failed and has been deferred for another try.
294
+ """
295
+ try:
296
+ await self._authenticator.authenticate_app()
297
+ except Exception as e:
298
+ self._defer_app_auth()
299
+ logger.warning(
300
+ "Re-authenticating the application failed (attempt %d), will retry: %s",
301
+ self._app_auth_attempts,
302
+ e,
303
+ )
304
+ return False
305
+
306
+ self._app_auth_pending = False
307
+ self._app_auth_attempts = 0
308
+ logger.info("Application re-authenticated; restoring accounts")
309
+ return True
310
+
240
311
  async def _wait_for_retry(self, delay: float, flagged: anyio.Event) -> None:
241
312
  """Wait out a backoff, returning early if another account needs recovery.
242
313
 
@@ -266,6 +337,11 @@ class SessionRecovery:
266
337
  and retries continue until every account succeeds, is removed, or the
267
338
  monitor stops. A successful re-auth restores authorized state and emits
268
339
  a ReadyEvent so subscriptions can be restored.
340
+
341
+ Also carries the application re-authentication a reconnect could not
342
+ complete. That one is not per-account and blocks every account behind
343
+ it, so it is settled first and the accounts are left flagged until it
344
+ succeeds.
269
345
  """
270
346
  while self._running:
271
347
  # Consume any pending notification before reading the queue, so a
@@ -274,6 +350,16 @@ class SessionRecovery:
274
350
  self._reauth_signal = anyio.Event()
275
351
  flagged = self._reauth_signal
276
352
 
353
+ if self._app_auth_pending:
354
+ waiting = self._app_auth_next_at - self._clock.now()
355
+ if waiting > 0:
356
+ await self._wait_for_retry(waiting, flagged)
357
+ continue
358
+ if not await self._retry_app_auth():
359
+ continue
360
+ # Authenticating the application is exactly what the flagged
361
+ # accounts were held up by, so go straight on to them.
362
+
277
363
  queue = self._store.awaiting_recovery()
278
364
  if not queue:
279
365
  await flagged.wait()
@@ -109,6 +109,11 @@ class Protocol:
109
109
  # reader loop and the heartbeat loop both detecting the same drop).
110
110
  self._reconnecting = False
111
111
 
112
+ # Set when the link drops again while a reconnection is already in
113
+ # flight. The detector that saw it cannot start its own reconnection,
114
+ # so the one already running goes round again on its behalf.
115
+ self._redo_requested = False
116
+
112
117
  # Told about link transitions; set by whoever owns this protocol.
113
118
  self._listener: ConnectionListener | None = None
114
119
 
@@ -444,11 +449,17 @@ class Protocol:
444
449
  standalone task decouples the reconnection from the cancel scope of
445
450
  whichever loop detected the drop.
446
451
 
447
- Idempotent: calls made while a reconnection is already in flight are
448
- ignored, so the reader and heartbeat loops racing to report the same
449
- drop only produce a single reconnection.
452
+ Idempotent: calls made while a reconnection is already in flight do not
453
+ start a second one, so the reader and heartbeat loops racing to report
454
+ the same drop only produce a single reconnection. Such a call is still
455
+ recorded, because it may be reporting a *fresh* drop of the link the
456
+ running reconnection just restored — and the caller cannot retry for
457
+ itself, since the reader loop exits as soon as it has reported.
450
458
  """
451
- if not self._running or self._reconnecting:
459
+ if not self._running:
460
+ return
461
+ if self._reconnecting:
462
+ self._redo_requested = True
452
463
  return
453
464
  if self._task_group is None:
454
465
  return
@@ -457,16 +468,25 @@ class Protocol:
457
468
  logger.warning("Connection lost, attempting to reconnect...")
458
469
 
459
470
  # State tied to the dead link is invalid from this moment on. Report it
460
- # before reconnecting so nothing observes a session that no longer
461
- # exists, and never let a failing listener block the reconnection.
462
- if self._listener is not None:
463
- try:
464
- await self._listener.on_connection_lost()
465
- except Exception:
466
- logger.exception("Connection listener failed on disconnect")
471
+ # before reconnecting so nothing observes a session that no longer exists.
472
+ await self._notify_connection_lost()
467
473
 
468
474
  self._task_group.start_soon(self._reconnect_task)
469
475
 
476
+ async def _notify_connection_lost(self) -> None:
477
+ """Tell the listener the link is gone.
478
+
479
+ Never lets a failing listener block the reconnection: whatever
480
+ bookkeeping it does matters less than getting the link back.
481
+ """
482
+ if self._listener is None:
483
+ return
484
+
485
+ try:
486
+ await self._listener.on_connection_lost()
487
+ except Exception:
488
+ logger.exception("Connection listener failed on disconnect")
489
+
470
490
  async def _reconnect_task(self) -> None:
471
491
  """Own the full reconnection lifecycle from a standalone task.
472
492
 
@@ -475,20 +495,44 @@ class Protocol:
475
495
  escape: an unhandled error here would propagate into the protocol task
476
496
  group and tear down every other task. A terminal reconnection failure is
477
497
  instead recorded by stopping the protocol and waking pending requests.
498
+
499
+ Loops rather than running once, so a link that drops again while this is
500
+ working — reported through `handle_disconnect` as `_redo_requested`, the
501
+ only route left once the reader has exited — is reconnected too instead
502
+ of being stranded.
478
503
  """
479
504
  try:
480
- # The reader loop is reading a dead stream; cancel it before we
481
- # reconnect so a stale reader can't race the fresh one we restart.
482
- if self._reader_scope is not None:
483
- self._reader_scope.cancel()
484
- self._reader_scope = None
485
-
486
- # Close the old transport (idempotent — the task that detected the
487
- # drop may have already raced us to it).
488
- await self._transport.close()
489
-
490
- await self._reconnect()
491
- logger.info("Reconnection successful")
505
+ while True:
506
+ self._redo_requested = False
507
+
508
+ # The reader loop is reading a dead stream; cancel it before we
509
+ # reconnect so a stale reader can't race the fresh one we restart.
510
+ if self._reader_scope is not None:
511
+ self._reader_scope.cancel()
512
+ self._reader_scope = None
513
+
514
+ # Close the old transport (idempotent — the task that detected
515
+ # the drop may have already raced us to it). Tearing down a dead
516
+ # socket can fail on its own, which says nothing about whether
517
+ # reconnecting will work: swallow it here so it can never be
518
+ # mistaken below for an exhausted reconnection.
519
+ try:
520
+ await self._transport.close()
521
+ except Exception:
522
+ logger.debug("Ignoring error while closing dead transport", exc_info=True)
523
+
524
+ await self._reconnect()
525
+ logger.info("Reconnection successful")
526
+
527
+ if not self._redo_requested:
528
+ break
529
+
530
+ logger.warning("Connection lost again while reconnecting, retrying...")
531
+ # The sessions this pass just restored died with the link that
532
+ # dropped underneath it, so report that loss before going round
533
+ # again — nothing else will, since `handle_disconnect` only
534
+ # recorded the drop rather than handling it.
535
+ await self._notify_connection_lost()
492
536
  except (CTraderConnectionFailedError, CTraderConnectionClosedError) as e:
493
537
  logger.error("Reconnection failed, giving up: %s", e)
494
538
  self._running = False
@@ -501,6 +545,7 @@ class Protocol:
501
545
  self._fail_pending("Reconnection error")
502
546
  finally:
503
547
  self._reconnecting = False
548
+ self._redo_requested = False
504
549
 
505
550
  def _fail_pending(self, reason: str) -> None:
506
551
  """Resolve every in-flight request with a connection error."""
@@ -104,7 +104,7 @@ class Transport:
104
104
  await stream.aclose()
105
105
  if close_scope.cancelled_caught:
106
106
  logger.debug("Graceful TLS shutdown timed out, forcing close")
107
- except (OSError, anyio.ClosedResourceError):
107
+ except (OSError, anyio.ClosedResourceError, anyio.BrokenResourceError):
108
108
  pass
109
109
 
110
110
  async def send(self, data: bytes) -> None:
@@ -324,8 +324,14 @@ class ReconnectedEvent:
324
324
  Use this event for any custom logic that depends on reconnection, such as logging or alerting.
325
325
 
326
326
  Attributes:
327
- app_auth_restored: Whether app authentication succeeded.
327
+ app_auth_restored: Whether app authentication succeeded on the attempt
328
+ this event reports. False is not final: the client keeps retrying
329
+ with backoff, and announces each account it restores afterwards with
330
+ a ReadyEvent. Check `client.auth.is_app_authenticated` for the
331
+ state now.
328
332
  restored_accounts: Account IDs that were successfully re-authenticated.
333
+ Empty when app authentication failed, since nothing can be
334
+ authorized before it succeeds — not a report that they were lost.
329
335
  failed_accounts: Accounts that failed, as (account_id, error_message) tuples.
330
336
  """
331
337
 
@@ -15,6 +15,7 @@ from ctrader_api_client._internal.proto import (
15
15
  ProtoOATraderRes,
16
16
  )
17
17
  from ctrader_api_client.composition import ClientGraph
18
+ from ctrader_api_client.connection import Transport
18
19
  from ctrader_api_client.exceptions import CTraderConnectionClosedError
19
20
 
20
21
  from ...harness import FakeServer, Recorder, factories
@@ -109,6 +110,102 @@ async def test_reconnection_restores_event_delivery(
109
110
  assert spots.only.bid == 111_111
110
111
 
111
112
 
113
+ async def test_closing_a_transport_whose_socket_is_broken_does_not_raise() -> None:
114
+ """A socket that refuses a graceful shutdown is still going away.
115
+
116
+ A broken TLS session raises `BrokenResourceError` out of `aclose()`, because
117
+ the close-notify it tries to write cannot be sent. That is the expected
118
+ ending for a link that is already dead, not a failure to report.
119
+ """
120
+ transport = Transport(host="stub.invalid", port=0, use_ssl=False)
121
+
122
+ class BrokenStream:
123
+ async def aclose(self) -> None:
124
+ raise anyio.BrokenResourceError
125
+
126
+ transport._stream = BrokenStream() # type: ignore[assignment] # noqa: SLF001 - no public seam for a broken stream
127
+
128
+ await transport.close()
129
+
130
+ assert transport.is_connected is False
131
+
132
+
133
+ @pytest.mark.usefixtures("echoing_trader")
134
+ async def test_reconnection_survives_a_transport_that_cannot_be_closed(
135
+ make_graph: Callable[..., ClientGraph],
136
+ connected: Callable[[CTraderClient], Awaitable[CTraderClient]],
137
+ server: FakeServer,
138
+ ) -> None:
139
+ """Failing to tear down the dead link must not abandon the reconnection.
140
+
141
+ Regression guard: closing the old transport is the first thing a
142
+ reconnection does, and on an already-broken socket that close can raise.
143
+ The exception used to be indistinguishable from an exhausted retry loop, so
144
+ the client stopped reconnecting permanently — while never having attempted
145
+ a single reconnection on that cycle.
146
+ """
147
+ graph = make_graph()
148
+ close_cleanly = graph.transport.close
149
+ refusals = [anyio.BrokenResourceError()]
150
+
151
+ async def close_raising_once() -> None:
152
+ # Raise *after* the real teardown, the way the broken socket did: the
153
+ # stream is released first, and only the graceful TLS shutdown fails.
154
+ await close_cleanly()
155
+ if refusals:
156
+ raise refusals.pop()
157
+
158
+ graph.transport.close = close_raising_once # type: ignore[method-assign]
159
+ client = await connected(CTraderClient.from_graph(graph))
160
+
161
+ await server.drop_connection()
162
+ await server.wait_for_connections(2)
163
+
164
+ response = await client.protocol.request(ProtoOATraderReq(ctid_trader_account_id=10), ProtoOATraderRes)
165
+
166
+ assert response.ctid_trader_account_id == 10
167
+
168
+
169
+ @pytest.mark.usefixtures("echoing_trader")
170
+ async def test_a_drop_reported_while_reconnecting_is_not_lost(
171
+ make_graph: Callable[..., ClientGraph],
172
+ connected: Callable[[CTraderClient], Awaitable[CTraderClient]],
173
+ server: FakeServer,
174
+ ) -> None:
175
+ """A drop reported mid-reconnection is acted on, not silently swallowed.
176
+
177
+ This is the only window in which nothing else can pick the report up: a
178
+ detector is refused while a reconnection is in flight, and the reader loop
179
+ exits as soon as it has reported, so the reconnection already running has to
180
+ go round again on its behalf. The report is made from a listener, which runs
181
+ inside that window by construction, and the link is left healthy so that no
182
+ *other* detector can mask the loss by noticing independently.
183
+ """
184
+ graph = make_graph()
185
+
186
+ class ReportsADropWhileTheReconnectionIsInFlight:
187
+ def __init__(self) -> None:
188
+ self.reported = False
189
+
190
+ async def on_connection_lost(self) -> None: ...
191
+
192
+ async def on_connection_restored(self) -> None:
193
+ if self.reported:
194
+ return
195
+ self.reported = True
196
+ await graph.protocol.handle_disconnect()
197
+
198
+ graph.supervisor.add_listener(ReportsADropWhileTheReconnectionIsInFlight())
199
+ client = await connected(CTraderClient.from_graph(graph))
200
+
201
+ await server.drop_connection()
202
+ await server.wait_for_connections(3)
203
+
204
+ response = await client.protocol.request(ProtoOATraderReq(ctid_trader_account_id=12), ProtoOATraderRes)
205
+
206
+ assert response.ctid_trader_account_id == 12
207
+
208
+
112
209
  @pytest.mark.usefixtures("echoing_trader")
113
210
  async def test_a_failing_listener_does_not_block_reconnection(
114
211
  make_graph: Callable[..., ClientGraph],
@@ -16,7 +16,7 @@ from ctrader_api_client._internal.proto import (
16
16
  ProtoOARefreshTokenReq,
17
17
  ProtoOATraderReq,
18
18
  )
19
- from ctrader_api_client.auth import AccountCredentials
19
+ from ctrader_api_client.auth import AccountCredentials, ReauthPolicy
20
20
  from ctrader_api_client.events import (
21
21
  AccountDisconnectEvent,
22
22
  ReadyEvent,
@@ -35,6 +35,12 @@ from ..harness import FakeServer, ManualClock, Recorder, factories
35
35
 
36
36
  ALMOST_EXPIRED = 10.0
37
37
  CHECK_INTERVAL = 60.0
38
+ RETRY_WAIT = 1.0
39
+
40
+ # The heartbeat loop and the token-refresh loop, both parked on the clock once a
41
+ # client is up. Captured rather than assumed where a test needs to tell a new
42
+ # sleeper apart from these.
43
+ SLEEPERS_WHEN_CONNECTED = 2
38
44
 
39
45
 
40
46
  async def authenticate(client: CTraderClient, server: FakeServer) -> AccountCredentials:
@@ -51,6 +57,20 @@ def rejecting(_request: betterproto.Message) -> betterproto.Message:
51
57
  return factories.error_res(error_code="ACCOUNT_NOT_AUTHORIZED")
52
58
 
53
59
 
60
+ def cannot_route_once() -> Callable[[betterproto.Message], betterproto.Message]:
61
+ """Refuse the first request the way a gateway refuses to route one, then relent.
62
+
63
+ `CANT_ROUTE_REQUEST` is transient and server-side: the same request a moment
64
+ later is routed normally.
65
+ """
66
+ refusals = [factories.error_res(error_code="CANT_ROUTE_REQUEST", description="Cannot route request")]
67
+
68
+ def respond(_request: betterproto.Message) -> betterproto.Message:
69
+ return refusals.pop() if refusals else factories.app_auth_res()
70
+
71
+ return respond
72
+
73
+
54
74
  def already_logged_in(_request: betterproto.Message) -> betterproto.Message:
55
75
  """Refuse the way the server refuses an account whose session is still live."""
56
76
  return factories.error_res(
@@ -352,6 +372,63 @@ async def test_a_reconnect_that_cannot_re_authenticate_the_app_says_so(
352
372
  assert reconnects.only.failed_accounts == ()
353
373
 
354
374
 
375
+ async def test_a_reconnect_keeps_retrying_application_authentication(
376
+ make_client: Callable[..., CTraderClient],
377
+ connected: Callable[[CTraderClient], Awaitable[CTraderClient]],
378
+ server: FakeServer,
379
+ ) -> None:
380
+ """A transient refusal must not cost the client the rest of its session.
381
+
382
+ Regression guard: application re-authentication used to be attempted exactly
383
+ once per reconnect. A single transient refusal left the link up but
384
+ unauthenticated, with no path back — every later request timed out, and
385
+ because the link itself was healthy nothing ever reconnected again.
386
+ """
387
+ client = await connected(make_client(reauth_policy=ReauthPolicy(min_wait=0.0, max_wait=0.0)))
388
+ await authenticate(client, server)
389
+ ready: Recorder[ReadyEvent] = Recorder()
390
+ client.register_handler(ReadyEvent, ready)
391
+ server.on(ProtoOAApplicationAuthReq, cannot_route_once())
392
+
393
+ await server.drop_connection()
394
+ await ready.wait_for(1)
395
+
396
+ assert client.is_account_authorized(factories.ACCOUNT_ID) is True
397
+ assert ready.only.account_id == factories.ACCOUNT_ID
398
+ assert ready.only.is_reconnect is True
399
+
400
+
401
+ async def test_retrying_application_authentication_waits_out_its_backoff(
402
+ make_client: Callable[..., CTraderClient],
403
+ connected: Callable[[CTraderClient], Awaitable[CTraderClient]],
404
+ server: FakeServer,
405
+ clock: ManualClock,
406
+ ) -> None:
407
+ """Retrying is paced by the policy, not spun as fast as the server refuses.
408
+
409
+ A server that keeps refusing is usually one under strain; hammering it as
410
+ fast as the loop can go would pin a core and make that worse.
411
+ """
412
+ client = await connected(make_client(reauth_policy=ReauthPolicy(min_wait=RETRY_WAIT, max_wait=RETRY_WAIT)))
413
+ await authenticate(client, server)
414
+ await clock.wait_for_sleepers(SLEEPERS_WHEN_CONNECTED)
415
+ parked = clock.sleeper_count
416
+ server.on(ProtoOAApplicationAuthReq, rejecting)
417
+
418
+ await server.drop_connection()
419
+ # The attempt the reconnection itself makes, which the server refuses.
420
+ await server.wait_for_request(ProtoOAApplicationAuthReq, 2)
421
+ # Recovery has taken it over and is parked on the backoff before retrying.
422
+ await clock.wait_for_sleepers(parked + 1)
423
+
424
+ assert len(server.requests_of(ProtoOAApplicationAuthReq)) == 2
425
+
426
+ await clock.advance(RETRY_WAIT)
427
+ await server.wait_for_request(ProtoOAApplicationAuthReq, 3)
428
+
429
+ assert len(server.requests_of(ProtoOAApplicationAuthReq)) == 3
430
+
431
+
355
432
  async def test_an_account_a_reconnect_could_not_restore_is_not_authorized(
356
433
  client: CTraderClient,
357
434
  server: FakeServer,
@@ -270,7 +270,7 @@ wheels = [
270
270
 
271
271
  [[package]]
272
272
  name = "ctrader-api-client"
273
- version = "0.7.2"
273
+ version = "0.7.3"
274
274
  source = { editable = "." }
275
275
  dependencies = [
276
276
  { name = "anyio" },