ctrader-api-client 0.7.1__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.1 → ctrader_api_client-0.7.3}/.gitignore +4 -0
  2. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/PKG-INFO +1 -1
  3. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/api/events.md +24 -4
  4. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/pyproject.toml +2 -1
  5. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/_recovery.py +128 -18
  6. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/_refresh.py +7 -0
  7. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/_session.py +38 -0
  8. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/manager.py +39 -0
  9. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/connection/protocol.py +68 -23
  10. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/connection/transport.py +1 -1
  11. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/events/types.py +7 -1
  12. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/auth/test_token_lifecycle.py +117 -17
  13. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/connection/test_reconnection.py +97 -0
  14. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/test_client.py +86 -3
  15. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/harness/factories.py +10 -0
  16. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/uv.lock +1 -1
  17. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/.claude/settings.local.json +0 -0
  18. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/.github/workflows/docs.yml +0 -0
  19. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/.pre-commit-config.yaml +0 -0
  20. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/.python-version +0 -0
  21. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/Justfile +0 -0
  22. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/LICENSE +0 -0
  23. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/README.md +0 -0
  24. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/api/accounts.md +0 -0
  25. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/api/client.md +0 -0
  26. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/api/enums.md +0 -0
  27. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/api/exceptions.md +0 -0
  28. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/api/market-data.md +0 -0
  29. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/api/models.md +0 -0
  30. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/api/symbols.md +0 -0
  31. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/api/trading.md +0 -0
  32. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/getting-started.md +0 -0
  33. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/docs/index.md +0 -0
  34. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/mkdocs.yml +0 -0
  35. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/protos/SOURCE +0 -0
  36. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/protos/VERSION +0 -0
  37. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/protos/update.sh +0 -0
  38. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/protos/vendor/OpenApiCommonMessages.proto +0 -0
  39. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
  40. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/protos/vendor/OpenApiMessages.proto +0 -0
  41. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/protos/vendor/OpenApiModelMessages.proto +0 -0
  42. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/scripts/fix_proto_imports.py +0 -0
  43. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/__init__.py +0 -0
  44. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/__init__.py +0 -0
  45. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/clock.py +0 -0
  46. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/conversions.py +0 -0
  47. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/messages.py +0 -0
  48. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
  49. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
  50. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
  51. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
  52. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
  53. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/_internal/serialization.py +0 -0
  54. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/api/__init__.py +0 -0
  55. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/api/_base.py +0 -0
  56. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/api/accounts.py +0 -0
  57. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/api/market_data.py +0 -0
  58. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/api/symbols.py +0 -0
  59. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/api/trading.py +0 -0
  60. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/__init__.py +0 -0
  61. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/credentials.py +0 -0
  62. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/policy.py +0 -0
  63. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/auth/store.py +0 -0
  64. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/client.py +0 -0
  65. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/composition.py +0 -0
  66. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/config.py +0 -0
  67. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/connection/__init__.py +0 -0
  68. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/connection/heartbeat.py +0 -0
  69. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/connection/listener.py +0 -0
  70. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/connection/supervisor.py +0 -0
  71. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/enums.py +0 -0
  72. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/events/__init__.py +0 -0
  73. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/events/_execution.py +0 -0
  74. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/events/emitter.py +0 -0
  75. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/events/publisher.py +0 -0
  76. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/events/router.py +0 -0
  77. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/exceptions.py +0 -0
  78. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/__init__.py +0 -0
  79. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/_base.py +0 -0
  80. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/account.py +0 -0
  81. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/deal.py +0 -0
  82. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/market_data.py +0 -0
  83. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/order.py +0 -0
  84. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/position.py +0 -0
  85. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/requests.py +0 -0
  86. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/models/symbol.py +0 -0
  87. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/src/ctrader_api_client/py.typed +0 -0
  88. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/__init__.py +0 -0
  89. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/__init__.py +0 -0
  90. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/api/__init__.py +0 -0
  91. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/api/conftest.py +0 -0
  92. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/api/test_market_data.py +0 -0
  93. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/api/test_reference_data.py +0 -0
  94. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/api/test_subscription_restore.py +0 -0
  95. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/api/test_trading.py +0 -0
  96. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/auth/__init__.py +0 -0
  97. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/auth/conftest.py +0 -0
  98. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/auth/test_authentication.py +0 -0
  99. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/connection/__init__.py +0 -0
  100. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/connection/conftest.py +0 -0
  101. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/connection/test_event_dispatch.py +0 -0
  102. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/connection/test_framing.py +0 -0
  103. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/connection/test_heartbeat.py +0 -0
  104. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/connection/test_request_response.py +0 -0
  105. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/events/__init__.py +0 -0
  106. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/events/conftest.py +0 -0
  107. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/events/test_routing.py +0 -0
  108. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/behavior/events/test_subscriptions.py +0 -0
  109. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/conftest.py +0 -0
  110. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/harness/__init__.py +0 -0
  111. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/harness/clock.py +0 -0
  112. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/harness/recorder.py +0 -0
  113. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/harness/server.py +0 -0
  114. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/harness/signals.py +0 -0
  115. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/harness/stub_protocol.py +0 -0
  116. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/harness/wire.py +0 -0
  117. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/unit/__init__.py +0 -0
  118. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/unit/test_api_errors.py +0 -0
  119. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/unit/test_market_data_conversion.py +0 -0
  120. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/unit/test_request_conversion.py +0 -0
  121. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/unit/test_symbol_conversion.py +0 -0
  122. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/unit/test_trading_records.py +0 -0
  123. {ctrader_api_client-0.7.1 → ctrader_api_client-0.7.3}/tests/unit/test_wire_format.py +0 -0
@@ -12,6 +12,10 @@ wheels/
12
12
  # IDE-specific files
13
13
  .idea/
14
14
  .vscode/
15
+
15
16
  # Test artifacts
16
17
  .coverage
17
18
  .pytest_cache/
19
+
20
+ # Built documentation
21
+ site/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ctrader-api-client
3
- Version: 0.7.1
3
+ Version: 0.7.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
@@ -161,10 +169,22 @@ drop. Check current authorization with `client.is_account_authorized(account_id)
161
169
 
162
170
  A disconnect the server reports is checked before it is published. Rotating an
163
171
  access token makes the server report one for a session it has not ended, so the
164
- client re-authenticates to find out which it is: an account that still holds its
165
- session is refused as already authorized, and nothing is published. What reaches
166
- a handler is therefore always a session that was really gone at the moment it
167
- was checked.
172
+ client asks whether the account is still served on this connection: if it is,
173
+ nothing is published. The check does not re-authenticate, because that answers
174
+ for the token as much as for the session, and the rotation prompting the report
175
+ is the very thing that would make such an answer unreliable.
176
+
177
+ The check also waits for any token refresh on that account to finish first.
178
+ Rotating a token means the server drops the old authorization and the client
179
+ establishes a new one, and in between the account is genuinely unauthorized — it
180
+ refuses everything, including the check. Since the refresh always begins before
181
+ the report it provokes, waiting for it to settle is what keeps a moment the
182
+ client itself created from reading as a lost session.
183
+
184
+ A check that cannot be completed — a timeout, a link that has just gone — is not
185
+ read as a disconnect either. The account is left as it was, and the next report
186
+ is checked afresh. What reaches a handler is therefore always a session the
187
+ server confirmed it was no longer serving.
168
188
 
169
189
  ::: ctrader_api_client.events.TokenInvalidatedEvent
170
190
  options:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrader-api-client"
3
- version = "0.7.1"
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
 
@@ -104,14 +113,32 @@ class SessionRecovery:
104
113
  unauthorized and publish a drop that never occurred, so the report is
105
114
  checked against the server before anything acts on it.
106
115
 
107
- Re-authenticating is the check: a session that is already live is
108
- refused with `ALREADY_LOGGED_IN`, which says the report was spurious and
109
- there is nothing to tell anyone. Any other answer means the session
110
- really did end, and only then is the drop published and recovery armed.
116
+ Every report the server sends for a token rotation arrives while the
117
+ client is replacing that authorization, and for the length of that
118
+ replacement the account really is unauthorized: it answers nothing, and
119
+ asking it anything would only confirm a state the client is already
120
+ undoing. So the check waits for the refresh to settle before it asks.
121
+ The refresh always starts before the report it provokes, so there is no
122
+ ordering in which the report escapes the wait.
123
+
124
+ The check itself asks whether the account is still served on this link
125
+ rather than trying to re-authorize it, since re-authorizing answers for
126
+ the token as much as for the session.
127
+
128
+ A check that cannot be completed is not an answer of no. The account is
129
+ left as it was, because nothing has said the session ended.
111
130
 
112
131
  Args:
113
132
  account_id: The cTID trader account ID reported as disconnected.
114
133
  """
134
+ if self._store.is_refreshing(account_id):
135
+ logger.debug(
136
+ "Account %d was reported as disconnected while its token was being "
137
+ "refreshed; waiting for that to settle before checking",
138
+ account_id,
139
+ )
140
+ await self._store.wait_for_refresh(account_id)
141
+
115
142
  credentials = self._store.credentials(account_id)
116
143
  if credentials is None or not self._store.is_authorized(account_id):
117
144
  return
@@ -121,30 +148,36 @@ class SessionRecovery:
121
148
 
122
149
  self._verifying.add(account_id)
123
150
  try:
124
- await self._authenticator.establish(credentials, AuthTrigger.ACCOUNT_REAUTH)
125
- except AccountAuthError as e:
126
- if e.is_already_authorized():
151
+ try:
152
+ alive = await self._authenticator.probe_session(account_id)
153
+ except Exception as e:
154
+ logger.debug(
155
+ "Could not check the disconnect reported for account %d, leaving it as it is: %s",
156
+ account_id,
157
+ e,
158
+ )
159
+ return
160
+
161
+ if alive:
127
162
  logger.debug(
128
163
  "Account %d reported as disconnected still holds its session; ignoring",
129
164
  account_id,
130
165
  )
131
166
  return
132
- await self._report_dropped(account_id, e)
133
- except Exception as e:
134
- await self._report_dropped(account_id, e)
167
+
168
+ await self._report_dropped(account_id)
135
169
  finally:
136
170
  self._verifying.discard(account_id)
137
171
 
138
- async def _report_dropped(self, account_id: int, error: Exception) -> None:
172
+ async def _report_dropped(self, account_id: int) -> None:
139
173
  """Publish a confirmed drop and arm recovery for it.
140
174
 
141
- Reached once the server has refused to re-authorize the account, so the
142
- session is genuinely gone and could not be re-established on the spot.
175
+ Reached once the server has stopped serving the account on this link, so
176
+ the session is genuinely gone.
143
177
  """
144
178
  logger.warning(
145
- "Account %d disconnected by server; scheduling re-authentication: %s",
179
+ "Account %d disconnected by server; scheduling re-authentication",
146
180
  account_id,
147
- error,
148
181
  )
149
182
  self._store.flag_for_recovery(account_id)
150
183
  self._reauth_signal.set()
@@ -157,6 +190,11 @@ class SessionRecovery:
157
190
  credentials are kept, so the sessions can be re-established once the
158
191
  link is back.
159
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
160
198
  self._store.invalidate_all()
161
199
 
162
200
  async def on_connection_restored(self) -> None:
@@ -166,6 +204,11 @@ class SessionRecovery:
166
204
  link is already back, and a caller can only act on a failure it is told
167
205
  about. Without application authentication nothing else can be
168
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.
169
212
  """
170
213
  logger.debug("Connection restored, re-authenticating...")
171
214
 
@@ -173,7 +216,8 @@ class SessionRecovery:
173
216
  await self._authenticator.authenticate_app()
174
217
  logger.debug("App re-authenticated successfully")
175
218
  except Exception as e:
176
- 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()
177
221
  await self._publisher.emit(
178
222
  ReconnectedEvent(
179
223
  app_auth_restored=False,
@@ -204,15 +248,66 @@ class SessionRecovery:
204
248
  )
205
249
  )
206
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
+
207
255
  def _defer_recovery(self, account_id: int, state: AwaitingRecovery) -> None:
208
256
  """Back off before the next recovery attempt for a single account."""
209
257
  attempts = state.attempts + 1
210
- delay = min(self._policy.min_wait * 2 ** (attempts - 1), self._policy.max_wait)
211
258
  self._store.reschedule_recovery(
212
259
  account_id,
213
- 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
+ ),
214
264
  )
215
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
+
216
311
  async def _wait_for_retry(self, delay: float, flagged: anyio.Event) -> None:
217
312
  """Wait out a backoff, returning early if another account needs recovery.
218
313
 
@@ -242,6 +337,11 @@ class SessionRecovery:
242
337
  and retries continue until every account succeeds, is removed, or the
243
338
  monitor stops. A successful re-auth restores authorized state and emits
244
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.
245
345
  """
246
346
  while self._running:
247
347
  # Consume any pending notification before reading the queue, so a
@@ -250,6 +350,16 @@ class SessionRecovery:
250
350
  self._reauth_signal = anyio.Event()
251
351
  flagged = self._reauth_signal
252
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
+
253
363
  queue = self._store.awaiting_recovery()
254
364
  if not queue:
255
365
  await flagged.wait()
@@ -142,6 +142,11 @@ class TokenRefresher:
142
142
 
143
143
  last_error: Exception | None = None
144
144
 
145
+ # Held from before the request goes out until the account is authorized
146
+ # again, because the disconnect the server reports for the old token
147
+ # arrives inside that window and means nothing about the session.
148
+ self._store.begin_refresh(account_id)
149
+
145
150
  try:
146
151
  async for attempt in AsyncRetrying(
147
152
  stop=stop_after_attempt(self._policy.retry_attempts),
@@ -198,6 +203,8 @@ class TokenRefresher:
198
203
  last_error = e
199
204
  except Exception as e:
200
205
  last_error = e
206
+ finally:
207
+ self._store.end_refresh(account_id)
201
208
 
202
209
  if last_error is not None:
203
210
  raise TokenRefreshError(account_id, last_error)
@@ -17,6 +17,8 @@ from __future__ import annotations
17
17
  from dataclasses import dataclass
18
18
  from typing import Protocol as TypingProtocol
19
19
 
20
+ import anyio
21
+
20
22
  from ..enums import AuthTrigger
21
23
  from .credentials import AccountCredentials
22
24
 
@@ -77,6 +79,15 @@ class SessionAuthenticator(TypingProtocol):
77
79
  """Authorize an account, recording why so the right events follow."""
78
80
  ...
79
81
 
82
+ async def probe_session(self, account_id: int, timeout: float = 10.0) -> bool:
83
+ """Whether the account still holds a live session on this link.
84
+
85
+ Raises whatever stopped the question from being answered — including a
86
+ refusal that is about the token rather than the session — which is not
87
+ the same as an answer of no.
88
+ """
89
+ ...
90
+
80
91
 
81
92
  class SessionStore:
82
93
  """Every account the client knows about, keyed by cTID trader account ID.
@@ -90,6 +101,33 @@ class SessionStore:
90
101
  def __init__(self) -> None:
91
102
  self._accounts: dict[int, TrackedAccount] = {}
92
103
 
104
+ # Accounts whose authorization is being replaced by a token refresh.
105
+ # The server drops the old authorization as it issues the new pair, so
106
+ # for that stretch the account is genuinely unauthorized and answers
107
+ # nothing — a state no observer should mistake for a lost session.
108
+ self._refreshing: dict[int, anyio.Event] = {}
109
+
110
+ def begin_refresh(self, account_id: int) -> None:
111
+ """Note that the account's authorization is being replaced."""
112
+ if account_id not in self._refreshing:
113
+ self._refreshing[account_id] = anyio.Event()
114
+
115
+ def end_refresh(self, account_id: int) -> None:
116
+ """Note that it has been replaced, or that the attempt gave up."""
117
+ settled = self._refreshing.pop(account_id, None)
118
+ if settled is not None:
119
+ settled.set()
120
+
121
+ def is_refreshing(self, account_id: int) -> bool:
122
+ """Whether a refresh is currently replacing the account's authorization."""
123
+ return account_id in self._refreshing
124
+
125
+ async def wait_for_refresh(self, account_id: int) -> None:
126
+ """Wait until no refresh is replacing the account's authorization."""
127
+ settled = self._refreshing.get(account_id)
128
+ if settled is not None:
129
+ await settled.wait()
130
+
93
131
  def account_ids(self) -> list[int]:
94
132
  """Every account held, oldest first."""
95
133
  return list(self._accounts)
@@ -15,6 +15,8 @@ from .._internal.proto import (
15
15
  ProtoOAAccountAuthRes,
16
16
  ProtoOAApplicationAuthReq,
17
17
  ProtoOAApplicationAuthRes,
18
+ ProtoOATraderReq,
19
+ ProtoOATraderRes,
18
20
  )
19
21
  from ..enums import AuthTrigger
20
22
  from ..events import EventPublisher, ReadyEvent
@@ -238,6 +240,43 @@ class AuthManager:
238
240
  await self._restorer.restore(credentials.account_id)
239
241
  await self._publisher.emit(ReadyEvent(account_id=credentials.account_id, trigger=trigger))
240
242
 
243
+ async def probe_session(self, account_id: int, timeout: float = 10.0) -> bool:
244
+ """Whether the account still holds a live session on this link.
245
+
246
+ Asks the server for something only an authorized account is told. The
247
+ request carries no access token of its own, but the authorization behind
248
+ it does: while a rotation is in flight the server refuses everything for
249
+ the account, including this. That refusal names the token, and it is
250
+ raised rather than answered, because it says nothing about the session.
251
+
252
+ Args:
253
+ account_id: The cTID trader account ID.
254
+ timeout: Request timeout in seconds.
255
+
256
+ Returns:
257
+ True if the server still serves the account on this link.
258
+
259
+ Raises:
260
+ APIError: If the refusal is about the token rather than the account.
261
+ CTraderConnectionTimeoutError: If the request times out.
262
+ CTraderConnectionClosedError: If the link is gone.
263
+ """
264
+ request = ProtoOATraderReq(ctid_trader_account_id=account_id)
265
+
266
+ try:
267
+ await self._protocol.request(request, ProtoOATraderRes, timeout=timeout)
268
+ except APIError as e:
269
+ logger.debug(
270
+ "Account %d was refused a trader lookup: %s (%s)",
271
+ account_id,
272
+ e.error_code,
273
+ e.description,
274
+ )
275
+ if e.is_token_failure():
276
+ raise
277
+ return False
278
+ return True
279
+
241
280
  def remove_account(self, account_id: int) -> bool:
242
281
  """Remove an account from refresh monitoring.
243
282
 
@@ -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