ctrader-api-client 0.7.2__tar.gz → 0.8.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/PKG-INFO +7 -3
  2. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/README.md +6 -2
  3. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/api/events.md +8 -0
  4. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/api/exceptions.md +26 -0
  5. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/getting-started.md +11 -2
  6. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/pyproject.toml +2 -1
  7. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/__init__.py +2 -0
  8. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/_recovery.py +89 -3
  9. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/composition.py +1 -0
  10. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/config.py +8 -2
  11. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/connection/protocol.py +129 -38
  12. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/connection/transport.py +31 -13
  13. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/events/types.py +7 -1
  14. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/exceptions.py +33 -0
  15. ctrader_api_client-0.8.0/tests/behavior/connection/test_reconnection.py +476 -0
  16. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/test_client.py +78 -1
  17. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/uv.lock +1 -1
  18. ctrader_api_client-0.7.2/tests/behavior/connection/test_reconnection.py +0 -161
  19. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/.claude/settings.local.json +0 -0
  20. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/.github/workflows/docs.yml +0 -0
  21. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/.gitignore +0 -0
  22. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/.pre-commit-config.yaml +0 -0
  23. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/.python-version +0 -0
  24. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/Justfile +0 -0
  25. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/LICENSE +0 -0
  26. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/api/accounts.md +0 -0
  27. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/api/client.md +0 -0
  28. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/api/enums.md +0 -0
  29. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/api/market-data.md +0 -0
  30. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/api/models.md +0 -0
  31. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/api/symbols.md +0 -0
  32. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/api/trading.md +0 -0
  33. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/index.md +0 -0
  34. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/mkdocs.yml +0 -0
  35. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/protos/SOURCE +0 -0
  36. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/protos/VERSION +0 -0
  37. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/protos/update.sh +0 -0
  38. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/protos/vendor/OpenApiCommonMessages.proto +0 -0
  39. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
  40. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/protos/vendor/OpenApiMessages.proto +0 -0
  41. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/protos/vendor/OpenApiModelMessages.proto +0 -0
  42. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/scripts/fix_proto_imports.py +0 -0
  43. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/__init__.py +0 -0
  44. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/clock.py +0 -0
  45. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/conversions.py +0 -0
  46. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/messages.py +0 -0
  47. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
  48. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
  49. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
  50. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
  51. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
  52. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/serialization.py +0 -0
  53. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/api/__init__.py +0 -0
  54. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/api/_base.py +0 -0
  55. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/api/accounts.py +0 -0
  56. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/api/market_data.py +0 -0
  57. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/api/symbols.py +0 -0
  58. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/api/trading.py +0 -0
  59. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/__init__.py +0 -0
  60. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/_refresh.py +0 -0
  61. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/_session.py +0 -0
  62. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/credentials.py +0 -0
  63. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/manager.py +0 -0
  64. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/policy.py +0 -0
  65. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/store.py +0 -0
  66. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/client.py +0 -0
  67. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/connection/__init__.py +0 -0
  68. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/connection/heartbeat.py +0 -0
  69. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/connection/listener.py +0 -0
  70. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/connection/supervisor.py +0 -0
  71. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/enums.py +0 -0
  72. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/events/__init__.py +0 -0
  73. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/events/_execution.py +0 -0
  74. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/events/emitter.py +0 -0
  75. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/events/publisher.py +0 -0
  76. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/events/router.py +0 -0
  77. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/__init__.py +0 -0
  78. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/_base.py +0 -0
  79. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/account.py +0 -0
  80. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/deal.py +0 -0
  81. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/market_data.py +0 -0
  82. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/order.py +0 -0
  83. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/position.py +0 -0
  84. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/requests.py +0 -0
  85. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/symbol.py +0 -0
  86. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/py.typed +0 -0
  87. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/__init__.py +0 -0
  88. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/__init__.py +0 -0
  89. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/api/__init__.py +0 -0
  90. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/api/conftest.py +0 -0
  91. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/api/test_market_data.py +0 -0
  92. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/api/test_reference_data.py +0 -0
  93. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/api/test_subscription_restore.py +0 -0
  94. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/api/test_trading.py +0 -0
  95. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/auth/__init__.py +0 -0
  96. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/auth/conftest.py +0 -0
  97. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/auth/test_authentication.py +0 -0
  98. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/auth/test_token_lifecycle.py +0 -0
  99. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/connection/__init__.py +0 -0
  100. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/connection/conftest.py +0 -0
  101. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/connection/test_event_dispatch.py +0 -0
  102. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/connection/test_framing.py +0 -0
  103. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/connection/test_heartbeat.py +0 -0
  104. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/connection/test_request_response.py +0 -0
  105. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/events/__init__.py +0 -0
  106. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/events/conftest.py +0 -0
  107. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/events/test_routing.py +0 -0
  108. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/events/test_subscriptions.py +0 -0
  109. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/conftest.py +0 -0
  110. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/harness/__init__.py +0 -0
  111. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/harness/clock.py +0 -0
  112. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/harness/factories.py +0 -0
  113. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/harness/recorder.py +0 -0
  114. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/harness/server.py +0 -0
  115. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/harness/signals.py +0 -0
  116. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/harness/stub_protocol.py +0 -0
  117. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/harness/wire.py +0 -0
  118. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/unit/__init__.py +0 -0
  119. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/unit/test_api_errors.py +0 -0
  120. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/unit/test_market_data_conversion.py +0 -0
  121. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/unit/test_request_conversion.py +0 -0
  122. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/unit/test_symbol_conversion.py +0 -0
  123. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/unit/test_trading_records.py +0 -0
  124. {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/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.8.0
4
4
  Summary: API Client to interact with the cTrader Open API spec
5
5
  Author-email: Elio <elioachukri@pm.me>
6
6
  License-File: LICENSE
@@ -354,10 +354,14 @@ config = ClientConfig(
354
354
  heartbeat_timeout=30.0, # Or 0 to disable server heartbeat checks
355
355
  request_timeout=30.0,
356
356
 
357
- # Reconnection
358
- reconnect_attempts=5,
357
+ # Reconnection: None retries for as long as the client is open (the
358
+ # default), an integer caps the attempts, 0 disables reconnection. A
359
+ # finite budget that runs out raises CTraderReconnectAbandonedError out
360
+ # of the `async with client:` block.
361
+ reconnect_attempts=None,
359
362
  reconnect_min_wait=1.0,
360
363
  reconnect_max_wait=60.0,
364
+ connect_timeout=30.0,
361
365
 
362
366
  # Token refresh: when to refresh access tokens and how hard to retry
363
367
  refresh_policy=RefreshPolicy(
@@ -335,10 +335,14 @@ config = ClientConfig(
335
335
  heartbeat_timeout=30.0, # Or 0 to disable server heartbeat checks
336
336
  request_timeout=30.0,
337
337
 
338
- # Reconnection
339
- reconnect_attempts=5,
338
+ # Reconnection: None retries for as long as the client is open (the
339
+ # default), an integer caps the attempts, 0 disables reconnection. A
340
+ # finite budget that runs out raises CTraderReconnectAbandonedError out
341
+ # of the `async with client:` block.
342
+ reconnect_attempts=None,
340
343
  reconnect_min_wait=1.0,
341
344
  reconnect_max_wait=60.0,
345
+ connect_timeout=30.0,
342
346
 
343
347
  # Token refresh: when to refresh access tokens and how hard to retry
344
348
  refresh_policy=RefreshPolicy(
@@ -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
@@ -153,6 +153,32 @@ other handlers for that event.
153
153
  options:
154
154
  show_source: false
155
155
 
156
+ ::: ctrader_api_client.CTraderReconnectAbandonedError
157
+ options:
158
+ show_source: false
159
+
160
+ This is the one connection exception that reaches you without your having called
161
+ anything: it is raised out of the `async with client:` block itself. Reconnection
162
+ is unbounded by default, so seeing it means either that `reconnect_attempts` was
163
+ set to a finite number and every one was spent, or that something failed which
164
+ the reconnection logic did not anticipate.
165
+
166
+ ```python
167
+ while True:
168
+ try:
169
+ async with client:
170
+ await asyncio.Event().wait()
171
+ except CTraderReconnectAbandonedError:
172
+ logger.exception("Client abandoned its connection; starting a new one")
173
+ await asyncio.sleep(30)
174
+ ```
175
+
176
+ Catching it is only worthwhile if you intend to build a fresh client, since the
177
+ one that raised it is finished. Letting it propagate and having a process
178
+ supervisor restart you is an equally good answer, and the reason it is raised
179
+ rather than logged: a client that has stopped reconnecting has no reader and no
180
+ heartbeat left to notice anything, so nothing short of a restart brings it back.
181
+
156
182
  ### Authentication
157
183
 
158
184
  ::: ctrader_api_client.AuthenticationError
@@ -312,13 +312,22 @@ config = ClientConfig(
312
312
  heartbeat_timeout=0, # 0 to disable server heartbeat checks (default)
313
313
  request_timeout=30.0,
314
314
 
315
- # Reconnection
316
- reconnect_attempts=5,
315
+ # Reconnection: None retries for as long as the client is open (the
316
+ # default), an integer caps the attempts, 0 disables reconnection.
317
+ reconnect_attempts=None,
317
318
  reconnect_min_wait=1.0,
318
319
  reconnect_max_wait=60.0,
320
+ connect_timeout=30.0,
319
321
  )
320
322
  ```
321
323
 
324
+ Reconnection is unbounded by default because a finite budget that runs out
325
+ leaves a client that can never recover: there is no reader and no heartbeat left
326
+ to notice anything, so it stays offline until the process restarts. If you do
327
+ set a finite `reconnect_attempts`, spending it raises
328
+ `CTraderReconnectAbandonedError` out of the `async with client:` block rather
329
+ than leaving the client alive and permanently disconnected.
330
+
322
331
  ## Next Steps
323
332
 
324
333
  - [API Reference - Client](api/client.md) - Full client documentation
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrader-api-client"
3
- version = "0.7.2"
3
+ version = "0.8.0"
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]
@@ -92,6 +92,7 @@ from .exceptions import (
92
92
  CTraderConnectionFailedError,
93
93
  CTraderConnectionTimeoutError,
94
94
  CTraderError,
95
+ CTraderReconnectAbandonedError,
95
96
  DeserializationError,
96
97
  FramingError,
97
98
  ProtocolError,
@@ -139,6 +140,7 @@ __all__ = [
139
140
  "CTraderConnectionFailedError",
140
141
  "CTraderConnectionTimeoutError",
141
142
  "CTraderError",
143
+ "CTraderReconnectAbandonedError",
142
144
  "ClientConfig",
143
145
  "ClientDisconnectEvent",
144
146
  "CloseDetail",
@@ -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()
@@ -78,6 +78,7 @@ def build_graph(
78
78
  host=config.host,
79
79
  port=config.port,
80
80
  use_ssl=config.use_ssl,
81
+ connect_timeout=config.connect_timeout,
81
82
  )
82
83
  protocol = Protocol(
83
84
  transport=transport,
@@ -17,7 +17,12 @@ class ClientConfig(BaseModel):
17
17
  heartbeat_interval: Seconds between heartbeat sends.
18
18
  heartbeat_timeout: Seconds without server-initiated messages before disconnect. Set to 0 to disable.
19
19
  request_timeout: Default timeout for API requests in seconds.
20
- reconnect_attempts: Max reconnection attempts (0 to disable).
20
+ connect_timeout: Seconds allowed for the TCP connection and TLS
21
+ handshake together, after which the attempt is failed and retried.
22
+ reconnect_attempts: Max reconnection attempts. None (the default)
23
+ retries for as long as the client is open. 0 disables reconnection.
24
+ A finite budget that runs out abandons the connection and raises
25
+ CTraderReconnectAbandonedError out of the `async with` block.
21
26
  reconnect_min_wait: Initial wait between reconnection attempts.
22
27
  reconnect_max_wait: Maximum wait between reconnection attempts.
23
28
  refresh_policy: When to refresh access tokens and how hard to try.
@@ -51,9 +56,10 @@ class ClientConfig(BaseModel):
51
56
 
52
57
  # Request settings
53
58
  request_timeout: float = Field(default=30.0, gt=0)
59
+ connect_timeout: float = Field(default=30.0, gt=0)
54
60
 
55
61
  # Reconnection settings
56
- reconnect_attempts: int = Field(default=5, ge=0)
62
+ reconnect_attempts: int | None = Field(default=None, ge=0)
57
63
  reconnect_min_wait: float = Field(default=1.0, gt=0)
58
64
  reconnect_max_wait: float = Field(default=60.0, gt=0)
59
65
 
@@ -12,6 +12,7 @@ from tenacity import (
12
12
  AsyncRetrying,
13
13
  retry_if_exception_type,
14
14
  stop_after_attempt,
15
+ stop_never,
15
16
  wait_exponential,
16
17
  )
17
18
 
@@ -29,6 +30,7 @@ from ..exceptions import (
29
30
  CTraderConnectionClosedError,
30
31
  CTraderConnectionFailedError,
31
32
  CTraderConnectionTimeoutError,
33
+ CTraderReconnectAbandonedError,
32
34
  FramingError,
33
35
  )
34
36
  from .listener import ConnectionListener
@@ -73,7 +75,7 @@ class Protocol:
73
75
  def __init__(
74
76
  self,
75
77
  transport: Transport,
76
- reconnect_attempts: int = 5,
78
+ reconnect_attempts: int | None = None,
77
79
  reconnect_min_wait: float = 1.0,
78
80
  reconnect_max_wait: float = 60.0,
79
81
  ) -> None:
@@ -81,7 +83,10 @@ class Protocol:
81
83
 
82
84
  Args:
83
85
  transport: The underlying transport for sending/receiving data.
84
- reconnect_attempts: Maximum reconnection attempts (0 to disable).
86
+ reconnect_attempts: Maximum reconnection attempts. None retries for
87
+ as long as the client is open, which is the default: an outage
88
+ that outlasts a finite budget otherwise leaves a client that
89
+ can never recover. 0 disables reconnection entirely.
85
90
  reconnect_min_wait: Initial wait between attempts (seconds).
86
91
  reconnect_max_wait: Maximum wait between attempts (seconds).
87
92
  """
@@ -109,6 +114,11 @@ class Protocol:
109
114
  # reader loop and the heartbeat loop both detecting the same drop).
110
115
  self._reconnecting = False
111
116
 
117
+ # Set when the link drops again while a reconnection is already in
118
+ # flight. The detector that saw it cannot start its own reconnection,
119
+ # so the one already running goes round again on its behalf.
120
+ self._redo_requested = False
121
+
112
122
  # Told about link transitions; set by whoever owns this protocol.
113
123
  self._listener: ConnectionListener | None = None
114
124
 
@@ -444,11 +454,17 @@ class Protocol:
444
454
  standalone task decouples the reconnection from the cancel scope of
445
455
  whichever loop detected the drop.
446
456
 
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.
457
+ Idempotent: calls made while a reconnection is already in flight do not
458
+ start a second one, so the reader and heartbeat loops racing to report
459
+ the same drop only produce a single reconnection. Such a call is still
460
+ recorded, because it may be reporting a *fresh* drop of the link the
461
+ running reconnection just restored — and the caller cannot retry for
462
+ itself, since the reader loop exits as soon as it has reported.
450
463
  """
451
- if not self._running or self._reconnecting:
464
+ if not self._running:
465
+ return
466
+ if self._reconnecting:
467
+ self._redo_requested = True
452
468
  return
453
469
  if self._task_group is None:
454
470
  return
@@ -457,16 +473,25 @@ class Protocol:
457
473
  logger.warning("Connection lost, attempting to reconnect...")
458
474
 
459
475
  # 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")
476
+ # before reconnecting so nothing observes a session that no longer exists.
477
+ await self._notify_connection_lost()
467
478
 
468
479
  self._task_group.start_soon(self._reconnect_task)
469
480
 
481
+ async def _notify_connection_lost(self) -> None:
482
+ """Tell the listener the link is gone.
483
+
484
+ Never lets a failing listener block the reconnection: whatever
485
+ bookkeeping it does matters less than getting the link back.
486
+ """
487
+ if self._listener is None:
488
+ return
489
+
490
+ try:
491
+ await self._listener.on_connection_lost()
492
+ except Exception:
493
+ logger.exception("Connection listener failed on disconnect")
494
+
470
495
  async def _reconnect_task(self) -> None:
471
496
  """Own the full reconnection lifecycle from a standalone task.
472
497
 
@@ -475,32 +500,90 @@ class Protocol:
475
500
  escape: an unhandled error here would propagate into the protocol task
476
501
  group and tear down every other task. A terminal reconnection failure is
477
502
  instead recorded by stopping the protocol and waking pending requests.
503
+
504
+ Loops rather than running once, so a link that drops again while this is
505
+ working — reported through `handle_disconnect` as `_redo_requested`, the
506
+ only route left once the reader has exited — is reconnected too instead
507
+ of being stranded.
508
+
509
+ Raises:
510
+ CTraderReconnectAbandonedError: If reconnection is given up on. This
511
+ is deliberately allowed to escape into the protocol task group
512
+ and tear the client down: see :meth:`_abandon`.
478
513
  """
479
514
  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")
515
+ while True:
516
+ self._redo_requested = False
517
+
518
+ # The reader loop is reading a dead stream; cancel it before we
519
+ # reconnect so a stale reader can't race the fresh one we restart.
520
+ if self._reader_scope is not None:
521
+ self._reader_scope.cancel()
522
+ self._reader_scope = None
523
+
524
+ # Close the old transport (idempotent — the task that detected
525
+ # the drop may have already raced us to it). Tearing down a dead
526
+ # socket can fail on its own, which says nothing about whether
527
+ # reconnecting will work: swallow it here so it can never be
528
+ # mistaken below for an exhausted reconnection.
529
+ try:
530
+ await self._transport.close()
531
+ except Exception:
532
+ logger.debug("Ignoring error while closing dead transport", exc_info=True)
533
+
534
+ await self._reconnect()
535
+ logger.info("Reconnection successful")
536
+
537
+ if not self._redo_requested:
538
+ break
539
+
540
+ logger.warning("Connection lost again while reconnecting, retrying...")
541
+ # The sessions this pass just restored died with the link that
542
+ # dropped underneath it, so report that loss before going round
543
+ # again — nothing else will, since `handle_disconnect` only
544
+ # recorded the drop rather than handling it.
545
+ await self._notify_connection_lost()
492
546
  except (CTraderConnectionFailedError, CTraderConnectionClosedError) as e:
493
547
  logger.error("Reconnection failed, giving up: %s", e)
494
- self._running = False
495
- # Wake all pending requests so callers fail fast instead of hanging.
496
- self._fail_pending("Connection lost and reconnection failed")
497
- except Exception:
498
- # Defensive: never let the reconnection task crash the task group.
548
+ self._abandon("connection lost and reconnection failed", e)
549
+ except Exception as e:
550
+ # Nothing here is expected to fail this way — the retry loop below
551
+ # absorbs every failure connecting — so treat it as a defect rather
552
+ # than a state to sit quietly in.
499
553
  logger.exception("Unexpected error while reconnecting")
500
- self._running = False
501
- self._fail_pending("Reconnection error")
554
+ self._abandon("unexpected error while reconnecting", e)
502
555
  finally:
503
556
  self._reconnecting = False
557
+ self._redo_requested = False
558
+
559
+ def _abandon(self, reason: str, cause: BaseException) -> None:
560
+ """Stop the protocol and make giving up impossible to miss.
561
+
562
+ A client that has stopped reconnecting cannot recover on its own: the
563
+ reader has exited, the heartbeat loop returns as soon as its next write
564
+ fails, and `handle_disconnect` refuses everything once `_running` is
565
+ False. Nothing is left that could try again. Staying alive in that state
566
+ only means a consumer polling `is_connected` sees a link that is down
567
+ and assumes, reasonably and wrongly, that something is working on it.
568
+
569
+ So the failure is raised instead of recorded. It escapes into the
570
+ protocol task group, through the supervisor's, and out of the
571
+ `async with client:` block, where a process supervisor will see it.
572
+
573
+ Raises:
574
+ CTraderReconnectAbandonedError: Always, unless reconnection was
575
+ disabled by configuration, which is a choice rather than a
576
+ failure and keeps the previous behaviour of a client that
577
+ rejects further requests.
578
+ """
579
+ self._running = False
580
+ # Wake all pending requests so callers fail fast instead of hanging.
581
+ self._fail_pending(reason)
582
+
583
+ if self._reconnect_attempts == 0:
584
+ return
585
+
586
+ raise CTraderReconnectAbandonedError(reason, cause) from cause
504
587
 
505
588
  def _fail_pending(self, reason: str) -> None:
506
589
  """Resolve every in-flight request with a connection error."""
@@ -511,27 +594,35 @@ class Protocol:
511
594
  async def _reconnect(self) -> None:
512
595
  """Attempt reconnection with exponential backoff.
513
596
 
597
+ Retries on *any* failure rather than on an enumerated set of exception
598
+ types. The budget exists to outlast a server that cannot be reached,
599
+ and an unrecognised exception is no evidence that it can be: a
600
+ whitelist here meant an exception nobody had thought of skipped the
601
+ retries entirely and went straight to being fatal.
602
+
514
603
  Raises:
515
- CTraderConnectionClosedError: If reconnection is disabled or all attempts fail.
516
- CTraderConnectionFailedError: If connection cannot be established.
604
+ CTraderConnectionClosedError: If reconnection is disabled.
605
+ CTraderConnectionFailedError: If every attempt in the budget failed.
517
606
  """
518
607
  if self._reconnect_attempts == 0:
519
608
  raise CTraderConnectionClosedError("Connection lost and reconnection disabled")
520
609
 
610
+ budget = "unlimited" if self._reconnect_attempts is None else self._reconnect_attempts
611
+
521
612
  async for attempt in AsyncRetrying(
522
- stop=stop_after_attempt(self._reconnect_attempts),
613
+ stop=stop_never if self._reconnect_attempts is None else stop_after_attempt(self._reconnect_attempts),
523
614
  wait=wait_exponential(
524
615
  min=self._reconnect_min_wait,
525
616
  max=self._reconnect_max_wait,
526
617
  ),
527
- retry=retry_if_exception_type(CTraderConnectionFailedError),
618
+ retry=retry_if_exception_type(Exception),
528
619
  reraise=True,
529
620
  ):
530
621
  with attempt:
531
622
  logger.debug(
532
- "Reconnection attempt %d/%d",
623
+ "Reconnection attempt %d/%s",
533
624
  attempt.retry_state.attempt_number,
534
- self._reconnect_attempts,
625
+ budget,
535
626
  )
536
627
  await self._transport.connect()
537
628