ctrader-api-client 0.7.3__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.

Potentially problematic release.


This version of ctrader-api-client might be problematic. Click here for more details.

Files changed (123) hide show
  1. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/PKG-INFO +7 -3
  2. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/README.md +6 -2
  3. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/docs/api/exceptions.md +26 -0
  4. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/docs/getting-started.md +11 -2
  5. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/pyproject.toml +1 -1
  6. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/__init__.py +2 -0
  7. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/composition.py +1 -0
  8. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/config.py +8 -2
  9. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/connection/protocol.py +61 -15
  10. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/connection/transport.py +30 -12
  11. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/exceptions.py +33 -0
  12. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/connection/test_reconnection.py +219 -1
  13. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/uv.lock +1 -1
  14. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/.claude/settings.local.json +0 -0
  15. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/.github/workflows/docs.yml +0 -0
  16. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/.gitignore +0 -0
  17. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/.pre-commit-config.yaml +0 -0
  18. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/.python-version +0 -0
  19. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/Justfile +0 -0
  20. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/LICENSE +0 -0
  21. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/docs/api/accounts.md +0 -0
  22. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/docs/api/client.md +0 -0
  23. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/docs/api/enums.md +0 -0
  24. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/docs/api/events.md +0 -0
  25. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/docs/api/market-data.md +0 -0
  26. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/docs/api/models.md +0 -0
  27. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/docs/api/symbols.md +0 -0
  28. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/docs/api/trading.md +0 -0
  29. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/docs/index.md +0 -0
  30. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/mkdocs.yml +0 -0
  31. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/protos/SOURCE +0 -0
  32. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/protos/VERSION +0 -0
  33. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/protos/update.sh +0 -0
  34. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/protos/vendor/OpenApiCommonMessages.proto +0 -0
  35. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
  36. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/protos/vendor/OpenApiMessages.proto +0 -0
  37. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/protos/vendor/OpenApiModelMessages.proto +0 -0
  38. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/scripts/fix_proto_imports.py +0 -0
  39. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/__init__.py +0 -0
  40. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/clock.py +0 -0
  41. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/conversions.py +0 -0
  42. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/messages.py +0 -0
  43. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
  44. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
  45. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
  46. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
  47. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
  48. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/serialization.py +0 -0
  49. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/api/__init__.py +0 -0
  50. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/api/_base.py +0 -0
  51. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/api/accounts.py +0 -0
  52. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/api/market_data.py +0 -0
  53. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/api/symbols.py +0 -0
  54. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/api/trading.py +0 -0
  55. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/__init__.py +0 -0
  56. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/_recovery.py +0 -0
  57. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/_refresh.py +0 -0
  58. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/_session.py +0 -0
  59. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/credentials.py +0 -0
  60. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/manager.py +0 -0
  61. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/policy.py +0 -0
  62. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/store.py +0 -0
  63. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/client.py +0 -0
  64. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/connection/__init__.py +0 -0
  65. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/connection/heartbeat.py +0 -0
  66. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/connection/listener.py +0 -0
  67. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/connection/supervisor.py +0 -0
  68. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/enums.py +0 -0
  69. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/events/__init__.py +0 -0
  70. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/events/_execution.py +0 -0
  71. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/events/emitter.py +0 -0
  72. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/events/publisher.py +0 -0
  73. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/events/router.py +0 -0
  74. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/events/types.py +0 -0
  75. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/__init__.py +0 -0
  76. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/_base.py +0 -0
  77. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/account.py +0 -0
  78. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/deal.py +0 -0
  79. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/market_data.py +0 -0
  80. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/order.py +0 -0
  81. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/position.py +0 -0
  82. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/requests.py +0 -0
  83. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/symbol.py +0 -0
  84. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/src/ctrader_api_client/py.typed +0 -0
  85. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/__init__.py +0 -0
  86. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/__init__.py +0 -0
  87. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/api/__init__.py +0 -0
  88. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/api/conftest.py +0 -0
  89. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/api/test_market_data.py +0 -0
  90. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/api/test_reference_data.py +0 -0
  91. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/api/test_subscription_restore.py +0 -0
  92. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/api/test_trading.py +0 -0
  93. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/auth/__init__.py +0 -0
  94. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/auth/conftest.py +0 -0
  95. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/auth/test_authentication.py +0 -0
  96. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/auth/test_token_lifecycle.py +0 -0
  97. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/connection/__init__.py +0 -0
  98. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/connection/conftest.py +0 -0
  99. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/connection/test_event_dispatch.py +0 -0
  100. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/connection/test_framing.py +0 -0
  101. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/connection/test_heartbeat.py +0 -0
  102. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/connection/test_request_response.py +0 -0
  103. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/events/__init__.py +0 -0
  104. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/events/conftest.py +0 -0
  105. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/events/test_routing.py +0 -0
  106. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/events/test_subscriptions.py +0 -0
  107. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/behavior/test_client.py +0 -0
  108. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/conftest.py +0 -0
  109. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/harness/__init__.py +0 -0
  110. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/harness/clock.py +0 -0
  111. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/harness/factories.py +0 -0
  112. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/harness/recorder.py +0 -0
  113. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/harness/server.py +0 -0
  114. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/harness/signals.py +0 -0
  115. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/harness/stub_protocol.py +0 -0
  116. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/harness/wire.py +0 -0
  117. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/unit/__init__.py +0 -0
  118. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/unit/test_api_errors.py +0 -0
  119. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/unit/test_market_data_conversion.py +0 -0
  120. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/unit/test_request_conversion.py +0 -0
  121. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/unit/test_symbol_conversion.py +0 -0
  122. {ctrader_api_client-0.7.3 → ctrader_api_client-0.8.0}/tests/unit/test_trading_records.py +0 -0
  123. {ctrader_api_client-0.7.3 → 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.3
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(
@@ -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.3"
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 = [
@@ -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",
@@ -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
  """
@@ -500,6 +505,11 @@ class Protocol:
500
505
  working — reported through `handle_disconnect` as `_redo_requested`, the
501
506
  only route left once the reader has exited — is reconnected too instead
502
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`.
503
513
  """
504
514
  try:
505
515
  while True:
@@ -535,18 +545,46 @@ class Protocol:
535
545
  await self._notify_connection_lost()
536
546
  except (CTraderConnectionFailedError, CTraderConnectionClosedError) as e:
537
547
  logger.error("Reconnection failed, giving up: %s", e)
538
- self._running = False
539
- # Wake all pending requests so callers fail fast instead of hanging.
540
- self._fail_pending("Connection lost and reconnection failed")
541
- except Exception:
542
- # 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.
543
553
  logger.exception("Unexpected error while reconnecting")
544
- self._running = False
545
- self._fail_pending("Reconnection error")
554
+ self._abandon("unexpected error while reconnecting", e)
546
555
  finally:
547
556
  self._reconnecting = False
548
557
  self._redo_requested = False
549
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
587
+
550
588
  def _fail_pending(self, reason: str) -> None:
551
589
  """Resolve every in-flight request with a connection error."""
552
590
  for pending in self._pending.values():
@@ -556,27 +594,35 @@ class Protocol:
556
594
  async def _reconnect(self) -> None:
557
595
  """Attempt reconnection with exponential backoff.
558
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
+
559
603
  Raises:
560
- CTraderConnectionClosedError: If reconnection is disabled or all attempts fail.
561
- CTraderConnectionFailedError: If connection cannot be established.
604
+ CTraderConnectionClosedError: If reconnection is disabled.
605
+ CTraderConnectionFailedError: If every attempt in the budget failed.
562
606
  """
563
607
  if self._reconnect_attempts == 0:
564
608
  raise CTraderConnectionClosedError("Connection lost and reconnection disabled")
565
609
 
610
+ budget = "unlimited" if self._reconnect_attempts is None else self._reconnect_attempts
611
+
566
612
  async for attempt in AsyncRetrying(
567
- stop=stop_after_attempt(self._reconnect_attempts),
613
+ stop=stop_never if self._reconnect_attempts is None else stop_after_attempt(self._reconnect_attempts),
568
614
  wait=wait_exponential(
569
615
  min=self._reconnect_min_wait,
570
616
  max=self._reconnect_max_wait,
571
617
  ),
572
- retry=retry_if_exception_type(CTraderConnectionFailedError),
618
+ retry=retry_if_exception_type(Exception),
573
619
  reraise=True,
574
620
  ):
575
621
  with attempt:
576
622
  logger.debug(
577
- "Reconnection attempt %d/%d",
623
+ "Reconnection attempt %d/%s",
578
624
  attempt.retry_state.attempt_number,
579
- self._reconnect_attempts,
625
+ budget,
580
626
  )
581
627
  await self._transport.connect()
582
628
 
@@ -18,17 +18,21 @@ class Transport:
18
18
  Handles raw socket connections without knowledge of protobuf or message semantics.
19
19
  """
20
20
 
21
- def __init__(self, host: str, port: int, use_ssl: bool = True) -> None:
21
+ def __init__(self, host: str, port: int, use_ssl: bool = True, connect_timeout: float = 30.0) -> None:
22
22
  """Initialize transport configuration.
23
23
 
24
24
  Args:
25
25
  host: The server hostname to connect to.
26
26
  port: The server port to connect to.
27
27
  use_ssl: Whether to use SSL/TLS encryption. Defaults to True.
28
+ connect_timeout: Seconds to allow for the TCP connection and the
29
+ TLS handshake together. Bounds the one failure mode a caller
30
+ cannot retry: a handshake that neither completes nor fails.
28
31
  """
29
32
  self._host = host
30
33
  self._port = port
31
34
  self._ssl = use_ssl
35
+ self._connect_timeout = connect_timeout
32
36
  self._stream: ByteStream | None = None
33
37
 
34
38
  @property
@@ -62,6 +66,12 @@ class Transport:
62
66
 
63
67
  If already connected, this method returns immediately.
64
68
 
69
+ Either returns with a usable stream or raises
70
+ `CTraderConnectionFailedError`. There is deliberately no third outcome:
71
+ anything that goes wrong on the way to a connection *is* a failure to
72
+ connect, whatever its type, so callers can retry on one exception
73
+ rather than on a list of them that has to be kept complete.
74
+
65
75
  Raises:
66
76
  CTraderConnectionFailedError: If connection cannot be established.
67
77
  """
@@ -69,17 +79,25 @@ class Transport:
69
79
  return # Already connected
70
80
 
71
81
  try:
72
- if self._ssl:
73
- ssl_context = ssl.create_default_context()
74
- self._stream = await anyio.connect_tcp(
75
- self._host,
76
- self._port,
77
- ssl_context=ssl_context,
78
- tls_standard_compatible=True,
79
- )
80
- else:
81
- self._stream = await anyio.connect_tcp(self._host, self._port)
82
- except OSError as e:
82
+ # Every failure is translated, not an enumerated set of them. A TLS
83
+ # handshake cut short by the peer surfaces as anyio's
84
+ # BrokenResourceError, which is not an OSError and so used to
85
+ # escape untranslated — past the retry loop that only knew
86
+ # CTraderConnectionFailedError, and into the handler that reads an
87
+ # unrecognised exception as fatal. Cancellation still passes
88
+ # through: anyio raises it from BaseException, not Exception.
89
+ with anyio.fail_after(self._connect_timeout):
90
+ if self._ssl:
91
+ ssl_context = ssl.create_default_context()
92
+ self._stream = await anyio.connect_tcp(
93
+ self._host,
94
+ self._port,
95
+ ssl_context=ssl_context,
96
+ tls_standard_compatible=True,
97
+ )
98
+ else:
99
+ self._stream = await anyio.connect_tcp(self._host, self._port)
100
+ except Exception as e:
83
101
  raise CTraderConnectionFailedError(self._host, self._port, e) from e
84
102
 
85
103
  if self._ssl:
@@ -68,6 +68,39 @@ class CTraderConnectionTimeoutError(CTraderConnectionError):
68
68
  super().__init__(f"{operation} timed out after {timeout_seconds}s")
69
69
 
70
70
 
71
+ class CTraderReconnectAbandonedError(CTraderConnectionError):
72
+ """The client tried to restore a dropped link and gave up.
73
+
74
+ Raised out of the `async with client:` block, tearing the client down
75
+ rather than leaving it alive but permanently offline. A client that has
76
+ stopped reconnecting can never recover on its own — there is no reader and
77
+ no heartbeat left to notice anything — so the only useful thing it can do
78
+ is say so where a supervisor will see it.
79
+
80
+ Reaching this is not expected. Reconnection is unbounded by default, so a
81
+ server that stays unreachable produces retries rather than this error. It
82
+ means either that `reconnect_attempts` was set to a finite number and every
83
+ one was used, or that something went wrong that the reconnection logic did
84
+ not anticipate.
85
+
86
+ Not raised when reconnection is disabled outright with
87
+ `reconnect_attempts=0`: not reconnecting is the documented outcome there,
88
+ not a failure.
89
+
90
+ Attributes:
91
+ reason: What was being attempted when it was abandoned.
92
+ cause: The final underlying failure, also set as `__cause__`.
93
+ """
94
+
95
+ def __init__(self, reason: str, cause: BaseException | None = None) -> None:
96
+ self.reason = reason
97
+ self.cause = cause
98
+ message = f"Reconnection abandoned: {reason}"
99
+ if cause:
100
+ message += f" ({cause})"
101
+ super().__init__(message)
102
+
103
+
71
104
  # =============================================================================
72
105
  # Authentication Errors
73
106
  # =============================================================================
@@ -3,7 +3,9 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
+ import ssl
6
7
  from collections.abc import Awaitable, Callable
8
+ from typing import Any
7
9
 
8
10
  import anyio
9
11
  import pytest
@@ -15,12 +17,64 @@ from ctrader_api_client._internal.proto import (
15
17
  ProtoOATraderRes,
16
18
  )
17
19
  from ctrader_api_client.composition import ClientGraph
20
+ from ctrader_api_client.config import ClientConfig
18
21
  from ctrader_api_client.connection import Transport
19
- from ctrader_api_client.exceptions import CTraderConnectionClosedError
22
+ from ctrader_api_client.exceptions import (
23
+ CTraderConnectionClosedError,
24
+ CTraderConnectionFailedError,
25
+ CTraderReconnectAbandonedError,
26
+ )
20
27
 
21
28
  from ...harness import FakeServer, Recorder, factories
22
29
 
23
30
 
31
+ # Everything a connection attempt has been seen to fail with, plus one thing it
32
+ # has not. The point of the last entry is that this list can never be complete:
33
+ # a new anyio or CPython release is free to raise something nobody here thought
34
+ # of, and the behaviour under test has to hold for that too. Tests below are
35
+ # parametrised over this rather than written per exception type, because a
36
+ # guard written against one exception is exactly what let the second outage
37
+ # through after the first was fixed.
38
+ CONNECT_FAILURES: list[Exception] = [
39
+ # What a TLS handshake cut short by the peer actually raises. This is the
40
+ # one that took production down: not an OSError, so it escaped translation.
41
+ anyio.BrokenResourceError(),
42
+ anyio.EndOfStream(),
43
+ anyio.ClosedResourceError(),
44
+ ssl.SSLEOFError("EOF occurred in violation of protocol"),
45
+ ssl.SSLCertVerificationError("certificate verify failed"),
46
+ OSError("connection refused"),
47
+ TimeoutError("timed out"),
48
+ RuntimeError("a failure mode nobody has predicted yet"),
49
+ ]
50
+
51
+
52
+ def failing_connect(
53
+ monkeypatch: pytest.MonkeyPatch,
54
+ failure: Exception,
55
+ times: int | None = 1,
56
+ ) -> None:
57
+ """Make the next `times` connection attempts fail with `failure`.
58
+
59
+ Patches anyio rather than `Transport.connect`, so that the transport's own
60
+ translation of the failure is part of what is under test. `times=None`
61
+ fails every attempt.
62
+ """
63
+ connect_tcp = anyio.connect_tcp
64
+ remaining = times
65
+
66
+ async def connect(*args: Any, **kwargs: Any) -> Any:
67
+ nonlocal remaining
68
+ if remaining is None:
69
+ raise failure
70
+ if remaining > 0:
71
+ remaining -= 1
72
+ raise failure
73
+ return await connect_tcp(*args, **kwargs)
74
+
75
+ monkeypatch.setattr(anyio, "connect_tcp", connect)
76
+
77
+
24
78
  @pytest.mark.usefixtures("echoing_trader")
25
79
  async def test_client_reconnects_after_the_server_drops_the_link(
26
80
  client: CTraderClient,
@@ -206,6 +260,170 @@ async def test_a_drop_reported_while_reconnecting_is_not_lost(
206
260
  assert response.ctid_trader_account_id == 12
207
261
 
208
262
 
263
+ @pytest.mark.parametrize("failure", CONNECT_FAILURES, ids=lambda failure: type(failure).__name__)
264
+ async def test_connecting_reports_every_failure_as_a_connection_failure(
265
+ failure: Exception,
266
+ monkeypatch: pytest.MonkeyPatch,
267
+ ) -> None:
268
+ """`connect()` has two outcomes, not three: a stream, or a failure to connect.
269
+
270
+ Regression guard: it used to translate only `OSError`, so anything else —
271
+ `BrokenResourceError` from a broken TLS handshake, in production — came out
272
+ as itself. Callers retry on the translated type, so an untranslated failure
273
+ skipped the retries and was read as fatal instead.
274
+ """
275
+ failing_connect(monkeypatch, failure)
276
+ transport = Transport(host="127.0.0.1", port=1, use_ssl=False)
277
+
278
+ with pytest.raises(CTraderConnectionFailedError) as raised:
279
+ await transport.connect()
280
+
281
+ assert raised.value.cause is failure
282
+ assert transport.is_connected is False
283
+
284
+
285
+ @pytest.mark.usefixtures("echoing_trader")
286
+ @pytest.mark.parametrize("failure", CONNECT_FAILURES, ids=lambda failure: type(failure).__name__)
287
+ async def test_reconnection_survives_any_failure_to_connect(
288
+ failure: Exception,
289
+ make_client: Callable[..., CTraderClient],
290
+ connected: Callable[[CTraderClient], Awaitable[CTraderClient]],
291
+ server: FakeServer,
292
+ monkeypatch: pytest.MonkeyPatch,
293
+ ) -> None:
294
+ """A failed connection attempt is a reason to try again, whatever it was.
295
+
296
+ Regression guard for the second production outage: a `BrokenResourceError`
297
+ from a TLS handshake the broker cut short went straight past a retry loop
298
+ that only recognised `CTraderConnectionFailedError`, and the client died on
299
+ attempt 1 of 5 without spending its budget. The exception type must not be
300
+ what decides whether the client survives, so this holds for a failure no
301
+ one has anticipated as much as for the one that happened.
302
+ """
303
+ client = await connected(make_client())
304
+ failing_connect(monkeypatch, failure)
305
+
306
+ await server.drop_connection()
307
+ await server.wait_for_connections(2)
308
+
309
+ response = await client.protocol.request(ProtoOATraderReq(ctid_trader_account_id=20), ProtoOATraderRes)
310
+
311
+ assert response.ctid_trader_account_id == 20
312
+
313
+
314
+ @pytest.mark.usefixtures("echoing_trader")
315
+ async def test_reconnection_outlasts_an_outage_longer_than_the_old_budget(
316
+ make_client: Callable[..., CTraderClient],
317
+ connected: Callable[[CTraderClient], Awaitable[CTraderClient]],
318
+ server: FakeServer,
319
+ monkeypatch: pytest.MonkeyPatch,
320
+ ) -> None:
321
+ """Reconnection keeps going by default rather than running out.
322
+
323
+ The default used to be five attempts, roughly fifteen seconds of backoff —
324
+ less than a router reboot, after which the client was offline for good.
325
+ Eight consecutive failures here is more than that budget would have
326
+ survived, so this fails if the default ever becomes finite again.
327
+ """
328
+ client = await connected(make_client())
329
+ failing_connect(monkeypatch, anyio.BrokenResourceError(), times=8)
330
+
331
+ await server.drop_connection()
332
+ await server.wait_for_connections(2)
333
+
334
+ response = await client.protocol.request(ProtoOATraderReq(ctid_trader_account_id=21), ProtoOATraderRes)
335
+
336
+ assert response.ctid_trader_account_id == 21
337
+
338
+
339
+ def test_reconnection_is_unbounded_unless_configured_otherwise() -> None:
340
+ """The default is stated here so changing it has to be deliberate."""
341
+ config = ClientConfig(client_id="id", client_secret="secret")
342
+
343
+ assert config.reconnect_attempts is None
344
+
345
+
346
+ @pytest.mark.usefixtures("echoing_trader")
347
+ async def test_a_connection_attempt_that_hangs_is_given_up_on_and_retried(
348
+ make_client: Callable[..., CTraderClient],
349
+ connected: Callable[[CTraderClient], Awaitable[CTraderClient]],
350
+ server: FakeServer,
351
+ monkeypatch: pytest.MonkeyPatch,
352
+ ) -> None:
353
+ """A handshake that never finishes is bounded, not waited on forever.
354
+
355
+ The failure with no exception at all: a TLS handshake that neither
356
+ completes nor fails leaves the reconnection parked on it, which looks
357
+ exactly like the permanent outage a raised exception used to cause and
358
+ leaves nothing in the log to say so.
359
+ """
360
+ client = await connected(make_client(connect_timeout=0.05))
361
+ connect_tcp = anyio.connect_tcp
362
+ hung = False
363
+
364
+ async def connect(*args: Any, **kwargs: Any) -> Any:
365
+ nonlocal hung
366
+ if not hung:
367
+ hung = True
368
+ await anyio.sleep_forever()
369
+ return await connect_tcp(*args, **kwargs)
370
+
371
+ monkeypatch.setattr(anyio, "connect_tcp", connect)
372
+
373
+ await server.drop_connection()
374
+ await server.wait_for_connections(2)
375
+
376
+ response = await client.protocol.request(ProtoOATraderReq(ctid_trader_account_id=22), ProtoOATraderRes)
377
+
378
+ assert hung
379
+ assert response.ctid_trader_account_id == 22
380
+
381
+
382
+ async def test_abandoning_reconnection_raises_out_of_the_client_block(
383
+ make_client: Callable[..., CTraderClient],
384
+ server: FakeServer,
385
+ monkeypatch: pytest.MonkeyPatch,
386
+ ) -> None:
387
+ """Giving up tears the client down instead of leaving it quietly offline.
388
+
389
+ A client that has stopped reconnecting cannot recover on its own, so
390
+ staying alive only means a consumer polling `is_connected` sees a link that
391
+ is down and assumes something is working on restoring it. In production
392
+ that assumption was logged once every few seconds for twenty-three minutes.
393
+ """
394
+ client = make_client(reconnect_attempts=2)
395
+
396
+ with pytest.raises(CTraderReconnectAbandonedError) as raised:
397
+ async with client:
398
+ failing_connect(monkeypatch, anyio.BrokenResourceError(), times=None)
399
+ await server.drop_connection()
400
+ # Park until the abandonment cancels us; the deadline turns a
401
+ # regression into a failure rather than a hung suite.
402
+ with anyio.fail_after(10):
403
+ await anyio.sleep_forever()
404
+
405
+ assert isinstance(raised.value.cause, CTraderConnectionFailedError)
406
+
407
+
408
+ async def test_disabling_reconnection_does_not_tear_the_client_down(
409
+ make_client: Callable[..., CTraderClient],
410
+ connected: Callable[[CTraderClient], Awaitable[CTraderClient]],
411
+ server: FakeServer,
412
+ ) -> None:
413
+ """Not reconnecting on request is an outcome, not a failure to report.
414
+
415
+ The counterpart to the test above: abandonment is raised because the client
416
+ tried and could not, which is never true when it was told not to try.
417
+ """
418
+ client = await connected(make_client(reconnect_attempts=0))
419
+
420
+ await server.drop_connection()
421
+ await server.wait_for_disconnect()
422
+
423
+ with pytest.raises(CTraderConnectionClosedError):
424
+ await client.protocol.send_request(ProtoOATraderReq(ctid_trader_account_id=23), timeout=1)
425
+
426
+
209
427
  @pytest.mark.usefixtures("echoing_trader")
210
428
  async def test_a_failing_listener_does_not_block_reconnection(
211
429
  make_graph: Callable[..., ClientGraph],
@@ -270,7 +270,7 @@ wheels = [
270
270
 
271
271
  [[package]]
272
272
  name = "ctrader-api-client"
273
- version = "0.7.3"
273
+ version = "0.8.0"
274
274
  source = { editable = "." }
275
275
  dependencies = [
276
276
  { name = "anyio" },