ctrader-api-client 0.7.0__tar.gz → 0.7.1__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 (124) hide show
  1. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/PKG-INFO +1 -1
  2. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/api/events.md +7 -0
  3. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/pyproject.toml +1 -1
  4. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/_recovery.py +70 -9
  5. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/router.py +11 -10
  6. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/types.py +11 -5
  7. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/exceptions.py +9 -0
  8. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/auth/test_token_lifecycle.py +70 -21
  9. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/events/test_routing.py +3 -2
  10. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/test_client.py +34 -1
  11. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/harness/recorder.py +1 -1
  12. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/uv.lock +1 -1
  13. ctrader_api_client-0.7.0/HARDENING.md +0 -251
  14. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/.claude/settings.local.json +0 -0
  15. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/.github/workflows/docs.yml +0 -0
  16. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/.gitignore +0 -0
  17. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/.pre-commit-config.yaml +0 -0
  18. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/.python-version +0 -0
  19. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/Justfile +0 -0
  20. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/LICENSE +0 -0
  21. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/README.md +0 -0
  22. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/api/accounts.md +0 -0
  23. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/api/client.md +0 -0
  24. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/api/enums.md +0 -0
  25. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/api/exceptions.md +0 -0
  26. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/api/market-data.md +0 -0
  27. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/api/models.md +0 -0
  28. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/api/symbols.md +0 -0
  29. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/api/trading.md +0 -0
  30. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/getting-started.md +0 -0
  31. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/docs/index.md +0 -0
  32. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/mkdocs.yml +0 -0
  33. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/protos/SOURCE +0 -0
  34. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/protos/VERSION +0 -0
  35. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/protos/update.sh +0 -0
  36. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/protos/vendor/OpenApiCommonMessages.proto +0 -0
  37. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
  38. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/protos/vendor/OpenApiMessages.proto +0 -0
  39. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/protos/vendor/OpenApiModelMessages.proto +0 -0
  40. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/scripts/fix_proto_imports.py +0 -0
  41. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/__init__.py +0 -0
  42. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/__init__.py +0 -0
  43. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/clock.py +0 -0
  44. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/conversions.py +0 -0
  45. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/messages.py +0 -0
  46. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
  47. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
  48. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
  49. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
  50. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
  51. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/_internal/serialization.py +0 -0
  52. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/api/__init__.py +0 -0
  53. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/api/_base.py +0 -0
  54. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/api/accounts.py +0 -0
  55. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/api/market_data.py +0 -0
  56. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/api/symbols.py +0 -0
  57. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/api/trading.py +0 -0
  58. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/__init__.py +0 -0
  59. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/_refresh.py +0 -0
  60. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/_session.py +0 -0
  61. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/credentials.py +0 -0
  62. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/manager.py +0 -0
  63. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/policy.py +0 -0
  64. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/auth/store.py +0 -0
  65. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/client.py +0 -0
  66. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/composition.py +0 -0
  67. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/config.py +0 -0
  68. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/__init__.py +0 -0
  69. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/heartbeat.py +0 -0
  70. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/listener.py +0 -0
  71. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/protocol.py +0 -0
  72. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/supervisor.py +0 -0
  73. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/connection/transport.py +0 -0
  74. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/enums.py +0 -0
  75. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/__init__.py +0 -0
  76. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/_execution.py +0 -0
  77. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/emitter.py +0 -0
  78. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/events/publisher.py +0 -0
  79. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/__init__.py +0 -0
  80. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/_base.py +0 -0
  81. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/account.py +0 -0
  82. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/deal.py +0 -0
  83. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/market_data.py +0 -0
  84. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/order.py +0 -0
  85. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/position.py +0 -0
  86. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/requests.py +0 -0
  87. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/models/symbol.py +0 -0
  88. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/src/ctrader_api_client/py.typed +0 -0
  89. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/__init__.py +0 -0
  90. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/__init__.py +0 -0
  91. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/api/__init__.py +0 -0
  92. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/api/conftest.py +0 -0
  93. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/api/test_market_data.py +0 -0
  94. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/api/test_reference_data.py +0 -0
  95. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/api/test_subscription_restore.py +0 -0
  96. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/api/test_trading.py +0 -0
  97. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/auth/__init__.py +0 -0
  98. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/auth/conftest.py +0 -0
  99. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/auth/test_authentication.py +0 -0
  100. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/__init__.py +0 -0
  101. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/conftest.py +0 -0
  102. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/test_event_dispatch.py +0 -0
  103. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/test_framing.py +0 -0
  104. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/test_heartbeat.py +0 -0
  105. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/test_reconnection.py +0 -0
  106. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/connection/test_request_response.py +0 -0
  107. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/events/__init__.py +0 -0
  108. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/events/conftest.py +0 -0
  109. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/behavior/events/test_subscriptions.py +0 -0
  110. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/conftest.py +0 -0
  111. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/harness/__init__.py +0 -0
  112. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/harness/clock.py +0 -0
  113. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/harness/factories.py +0 -0
  114. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/harness/server.py +0 -0
  115. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/harness/signals.py +0 -0
  116. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/harness/stub_protocol.py +0 -0
  117. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/harness/wire.py +0 -0
  118. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/unit/__init__.py +0 -0
  119. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/unit/test_api_errors.py +0 -0
  120. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/unit/test_market_data_conversion.py +0 -0
  121. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/unit/test_request_conversion.py +0 -0
  122. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/unit/test_symbol_conversion.py +0 -0
  123. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/tests/unit/test_trading_records.py +0 -0
  124. {ctrader_api_client-0.7.0 → ctrader_api_client-0.7.1}/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.0
3
+ Version: 0.7.1
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
@@ -159,6 +159,13 @@ the existing connection with backoff and emits a `ReadyEvent` on success. The
159
159
  event is informational; subscribe to it only if you want to observe or log the
160
160
  drop. Check current authorization with `client.is_account_authorized(account_id)`.
161
161
 
162
+ A disconnect the server reports is checked before it is published. Rotating an
163
+ access token makes the server report one for a session it has not ended, so the
164
+ client re-authenticates to find out which it is: an account that still holds its
165
+ session is refused as already authorized, and nothing is published. What reaches
166
+ a handler is therefore always a session that was really gone at the moment it
167
+ was checked.
168
+
162
169
  ::: ctrader_api_client.events.TokenInvalidatedEvent
163
170
  options:
164
171
  show_source: false
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrader-api-client"
3
- version = "0.7.0"
3
+ version = "0.7.1"
4
4
  description = "API Client to interact with the cTrader Open API spec"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -14,7 +14,8 @@ import anyio.abc
14
14
 
15
15
  from .._internal import Clock, MonotonicClock
16
16
  from ..enums import AuthTrigger
17
- from ..events import EventPublisher, ReconnectedEvent
17
+ from ..events import AccountDisconnectEvent, EventPublisher, ReadyEvent, ReconnectedEvent
18
+ from ..exceptions import AccountAuthError
18
19
  from ._session import AwaitingRecovery, SessionAuthenticator, SessionStore
19
20
  from .policy import ReauthPolicy
20
21
 
@@ -55,6 +56,10 @@ class SessionRecovery:
55
56
  # Wakes the recovery monitor when an account needs re-authenticating
56
57
  self._reauth_signal = anyio.Event()
57
58
 
59
+ # Accounts whose reported disconnect is still being checked, so a
60
+ # repeated report does not start a second check against the server.
61
+ self._verifying: set[int] = set()
62
+
58
63
  self._task_scope: anyio.CancelScope | None = None
59
64
  self._running = False
60
65
 
@@ -90,24 +95,60 @@ class SessionRecovery:
90
95
  if self._task_scope is not None:
91
96
  self._task_scope.cancel()
92
97
 
93
- def handle_account_disconnect(self, account_id: int) -> None:
94
- """Handle a server-side account disconnect.
98
+ async def handle_account_disconnect(self, account_id: int) -> None:
99
+ """Establish whether a reported account disconnect actually happened.
100
+
101
+ The server reports a disconnect whenever the token behind an
102
+ authorization is rotated, even though the session on this channel is
103
+ untouched. Taking the report at face value would mark a working account
104
+ unauthorized and publish a drop that never occurred, so the report is
105
+ checked against the server before anything acts on it.
95
106
 
96
- Marks the account's session as no longer authorized and schedules
97
- recovery re-authentication on the existing connection. Idempotent while
98
- a recovery is already pending for the account.
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.
99
111
 
100
112
  Args:
101
113
  account_id: The cTID trader account ID reported as disconnected.
102
114
  """
103
- if not self._store.flag_for_recovery(account_id):
115
+ credentials = self._store.credentials(account_id)
116
+ if credentials is None or not self._store.is_authorized(account_id):
104
117
  return
105
118
 
106
- self._reauth_signal.set()
119
+ if account_id in self._verifying:
120
+ return
121
+
122
+ self._verifying.add(account_id)
123
+ try:
124
+ await self._authenticator.establish(credentials, AuthTrigger.ACCOUNT_REAUTH)
125
+ except AccountAuthError as e:
126
+ if e.is_already_authorized():
127
+ logger.debug(
128
+ "Account %d reported as disconnected still holds its session; ignoring",
129
+ account_id,
130
+ )
131
+ return
132
+ await self._report_dropped(account_id, e)
133
+ except Exception as e:
134
+ await self._report_dropped(account_id, e)
135
+ finally:
136
+ self._verifying.discard(account_id)
137
+
138
+ async def _report_dropped(self, account_id: int, error: Exception) -> None:
139
+ """Publish a confirmed drop and arm recovery for it.
140
+
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.
143
+ """
107
144
  logger.warning(
108
- "Account %d disconnected by server; scheduling re-authentication",
145
+ "Account %d disconnected by server; scheduling re-authentication: %s",
109
146
  account_id,
147
+ error,
110
148
  )
149
+ self._store.flag_for_recovery(account_id)
150
+ self._reauth_signal.set()
151
+ await self._publisher.emit(AccountDisconnectEvent(account_id=account_id))
111
152
 
112
153
  async def on_connection_lost(self) -> None:
113
154
  """Discard the sessions that died with the link.
@@ -229,6 +270,26 @@ class SessionRecovery:
229
270
  for account_id, credentials, state in due:
230
271
  try:
231
272
  await self._authenticator.establish(credentials, AuthTrigger.ACCOUNT_REAUTH)
273
+ except AccountAuthError as e:
274
+ if not e.is_already_authorized():
275
+ logger.warning(
276
+ "Recovery re-authentication for account %d failed, will retry: %s",
277
+ account_id,
278
+ e,
279
+ )
280
+ self._defer_recovery(account_id, state)
281
+ continue
282
+ # The session came back by some other route between
283
+ # attempts. Retrying could only be refused the same way, so
284
+ # take the account as recovered and announce it, noting that
285
+ # nothing re-applied the subscriptions the dead session took
286
+ # with it.
287
+ logger.warning(
288
+ "Account %d holds a session again; its subscriptions were not re-applied",
289
+ account_id,
290
+ )
291
+ self._store.authorize(credentials)
292
+ await self._publisher.emit(ReadyEvent(account_id=account_id, trigger=AuthTrigger.ACCOUNT_REAUTH))
232
293
  except Exception as e:
233
294
  logger.warning(
234
295
  "Recovery re-authentication for account %d failed, will retry: %s",
@@ -27,7 +27,6 @@ from ..models import Trendbar
27
27
  from ._execution import execution_event_from_proto
28
28
  from .emitter import EventEmitter
29
29
  from .types import (
30
- AccountDisconnectEvent,
31
30
  ClientDisconnectEvent,
32
31
  DepthEvent,
33
32
  DepthQuote,
@@ -52,8 +51,8 @@ logger = logging.getLogger(__name__)
52
51
  class SessionRecovery(TypingProtocol):
53
52
  """Whatever knows how to restore an account session the server dropped."""
54
53
 
55
- def handle_account_disconnect(self, account_id: int) -> None:
56
- """Schedule re-authentication for the disconnected account."""
54
+ async def handle_account_disconnect(self, account_id: int) -> None:
55
+ """Check the reported disconnect, then publish and recover a real one."""
57
56
  ...
58
57
 
59
58
 
@@ -276,13 +275,15 @@ class EventRouter:
276
275
  self,
277
276
  proto: ProtoOAAccountDisconnectEvent,
278
277
  ) -> None:
279
- """Convert ProtoOAAccountDisconnectEvent to AccountDisconnectEvent."""
280
- event = AccountDisconnectEvent(
281
- account_id=proto.ctid_trader_account_id,
282
- )
283
- logger.warning("Account %d disconnected by server", event.account_id)
284
- self._recovery.handle_account_disconnect(event.account_id)
285
- await self._emitter.emit(event)
278
+ """Hand a reported account disconnect to recovery, which publishes it.
279
+
280
+ The server reports a disconnect on every token rotation, for a session
281
+ it has not actually ended, so the report is checked before anyone hears
282
+ about it. Recovery does that checking and emits `AccountDisconnectEvent`
283
+ only for a drop that turns out to be real.
284
+ """
285
+ logger.debug("Account %d reported as disconnected by server", proto.ctid_trader_account_id)
286
+ await self._recovery.handle_account_disconnect(proto.ctid_trader_account_id)
286
287
 
287
288
  async def _handle_symbol_changed(
288
289
  self,
@@ -238,11 +238,17 @@ class ClientDisconnectEvent:
238
238
  class AccountDisconnectEvent:
239
239
  """Account disconnect event.
240
240
 
241
- Emitted when a specific account session is terminated by the server while
242
- the underlying connection stays up. The client handles recovery
243
- automatically: it re-authenticates the account on the existing connection
244
- with backoff until it succeeds, then emits a ReadyEvent. This event is
245
- informational; no user action is required to restore the session.
241
+ Emitted when an account's session has been terminated by the server while
242
+ the underlying connection stays up, and could not be re-established on the
243
+ spot. The client keeps re-authenticating on the existing connection with
244
+ backoff until it succeeds, then emits a ReadyEvent; no user action is
245
+ required to restore the session.
246
+
247
+ The server also reports a disconnect whenever the token behind an
248
+ authorization is rotated, for a session it has not actually ended. Those
249
+ reports are checked against the server and discarded, so they do not appear
250
+ here — every event published is a drop that outlived a re-authentication
251
+ attempt.
246
252
 
247
253
  Attributes:
248
254
  account_id: The cTID trader account ID.
@@ -108,6 +108,15 @@ class AccountAuthError(AuthenticationError):
108
108
  message += f" - {description}"
109
109
  super().__init__(message)
110
110
 
111
+ def is_already_authorized(self) -> bool:
112
+ """Whether the account was rejected for holding a live session already.
113
+
114
+ The server refuses to authorize an account twice on one channel. A
115
+ caller that was trying to reach exactly that state has already reached
116
+ it, so this is a rejection worth reading as success.
117
+ """
118
+ return self.error_code == ProtoOAErrorCode.ALREADY_LOGGED_IN.name
119
+
111
120
 
112
121
  class TokenExpiredError(AuthenticationError):
113
122
  """Access token has expired."""
@@ -8,9 +8,9 @@ import betterproto
8
8
  import pytest
9
9
 
10
10
  from ctrader_api_client._internal.proto import ProtoOAAccountAuthReq, ProtoOARefreshTokenReq
11
- from ctrader_api_client.auth import AuthManager, ReauthPolicy
11
+ from ctrader_api_client.auth import AuthManager, ReauthPolicy, SessionRecovery, SessionStore
12
12
  from ctrader_api_client.enums import AuthTrigger
13
- from ctrader_api_client.events import ReadyEvent, TokenRefreshFailedEvent
13
+ from ctrader_api_client.events import AccountDisconnectEvent, ReadyEvent, TokenRefreshFailedEvent
14
14
  from ctrader_api_client.exceptions import APIError, TokenExpiredError
15
15
 
16
16
  from ...harness import ManualClock, RecordingPublisher, RecordingStore, StubProtocol, factories
@@ -292,17 +292,55 @@ async def test_a_transient_store_outage_recovers_on_the_next_check(
292
292
  assert protocol.sent_of(ProtoOAAccountAuthReq)[-1].access_token == "new-access-token"
293
293
 
294
294
 
295
- async def test_a_disconnected_account_stops_being_authorized(
295
+ async def test_an_account_the_server_confirms_is_gone_stops_being_authorized(
296
296
  auth: AuthManager,
297
297
  monitors: Monitors,
298
298
  protocol: StubProtocol,
299
299
  clock: ManualClock,
300
+ publisher: RecordingPublisher,
300
301
  ) -> None:
301
- await start_with_account(auth, protocol, clock, expires_in=PLENTY_OF_TIME)
302
+ """A report the server stands behind is a real drop, and is published as one."""
303
+ protocol.respond_in_sequence(
304
+ ProtoOAAccountAuthReq,
305
+ [factories.account_auth_res(), APIError(error_code="TEMPORARY")],
306
+ )
307
+ await auth.authenticate_trader(factories.credentials(expires_in=PLENTY_OF_TIME))
308
+ await clock.wait_for_sleepers(SLEEPERS_WHEN_STARTED)
302
309
 
303
- monitors.recovery.handle_account_disconnect(factories.ACCOUNT_ID)
310
+ await monitors.recovery.handle_account_disconnect(factories.ACCOUNT_ID)
304
311
 
305
312
  assert not auth.is_account_authorized(factories.ACCOUNT_ID)
313
+ assert publisher.of(AccountDisconnectEvent) == [AccountDisconnectEvent(factories.ACCOUNT_ID)]
314
+
315
+
316
+ async def test_a_session_the_server_still_holds_is_left_alone(
317
+ auth: AuthManager,
318
+ monitors: Monitors,
319
+ protocol: StubProtocol,
320
+ clock: ManualClock,
321
+ publisher: RecordingPublisher,
322
+ ) -> None:
323
+ """A token rotation makes the server report a disconnect it did not perform.
324
+
325
+ Regression guard: taking that report at face value marked a working account
326
+ unauthorized, published a drop that never happened, and left recovery
327
+ retrying an account the server would only ever refuse as already logged in.
328
+ """
329
+ protocol.respond_in_sequence(
330
+ ProtoOAAccountAuthReq,
331
+ [
332
+ factories.account_auth_res(),
333
+ APIError(error_code="ALREADY_LOGGED_IN", description="Trading account is already authorized"),
334
+ ],
335
+ )
336
+ await auth.authenticate_trader(factories.credentials(expires_in=PLENTY_OF_TIME))
337
+ await clock.wait_for_sleepers(SLEEPERS_WHEN_STARTED)
338
+
339
+ await monitors.recovery.handle_account_disconnect(factories.ACCOUNT_ID)
340
+
341
+ assert auth.is_account_authorized(factories.ACCOUNT_ID)
342
+ assert publisher.of(AccountDisconnectEvent) == []
343
+ assert publisher.of(ReadyEvent) == [ReadyEvent(factories.ACCOUNT_ID, AuthTrigger.INITIAL)]
306
344
 
307
345
 
308
346
  async def test_a_disconnected_account_is_re_authenticated(
@@ -314,7 +352,7 @@ async def test_a_disconnected_account_is_re_authenticated(
314
352
  ) -> None:
315
353
  await start_with_account(auth, protocol, clock, expires_in=PLENTY_OF_TIME)
316
354
 
317
- monitors.recovery.handle_account_disconnect(factories.ACCOUNT_ID)
355
+ await monitors.recovery.handle_account_disconnect(factories.ACCOUNT_ID)
318
356
  await publisher.wait_for_type(ReadyEvent, count=2)
319
357
 
320
358
  assert auth.is_account_authorized(factories.ACCOUNT_ID)
@@ -332,6 +370,8 @@ async def test_recovery_keeps_trying_after_a_failure(
332
370
  ProtoOAAccountAuthReq,
333
371
  [
334
372
  factories.account_auth_res(),
373
+ # The check that confirms the drop, then the first retry after it.
374
+ APIError(error_code="TEMPORARY"),
335
375
  APIError(error_code="TEMPORARY"),
336
376
  factories.account_auth_res(),
337
377
  ],
@@ -339,7 +379,7 @@ async def test_recovery_keeps_trying_after_a_failure(
339
379
  await auth.authenticate_trader(factories.credentials(expires_in=PLENTY_OF_TIME))
340
380
  await clock.wait_for_sleepers(SLEEPERS_WHEN_STARTED)
341
381
 
342
- monitors.recovery.handle_account_disconnect(factories.ACCOUNT_ID)
382
+ await monitors.recovery.handle_account_disconnect(factories.ACCOUNT_ID)
343
383
  # The failed attempt arms a backoff; time has to move for the retry.
344
384
  await clock.wait_for_sleepers(SLEEPERS_WHEN_STARTED + 1)
345
385
  await clock.advance(1.0)
@@ -372,12 +412,11 @@ async def test_an_account_dropped_during_another_backoff_is_recovered_at_once(
372
412
 
373
413
  protocol.respond_with(ProtoOAAccountAuthReq, answer)
374
414
 
375
- monitors.recovery.handle_account_disconnect(stubborn)
415
+ await monitors.recovery.handle_account_disconnect(stubborn)
376
416
  # Its failed attempt parks recovery on a ten second backoff.
377
417
  await clock.wait_for_sleepers(SLEEPERS_WHEN_STARTED + 1)
378
418
 
379
- monitors.recovery.handle_account_disconnect(healthy)
380
- await protocol.wait_for_sent(ProtoOAAccountAuthReq, count=4)
419
+ await monitors.recovery.handle_account_disconnect(healthy)
381
420
 
382
421
  assert auth.is_account_authorized(healthy)
383
422
  assert not auth.is_account_authorized(stubborn)
@@ -391,26 +430,36 @@ async def test_a_disconnect_for_an_unknown_account_is_ignored(
391
430
  ) -> None:
392
431
  await start_with_account(auth, protocol, clock, expires_in=PLENTY_OF_TIME)
393
432
 
394
- monitors.recovery.handle_account_disconnect(999_999)
433
+ await monitors.recovery.handle_account_disconnect(999_999)
395
434
  await clock.wait_for_sleepers(SLEEPERS_WHEN_STARTED)
396
435
 
397
436
  assert len(protocol.sent_of(ProtoOAAccountAuthReq)) == 1
398
437
  assert auth.is_account_authorized(factories.ACCOUNT_ID)
399
438
 
400
439
 
401
- async def test_repeated_disconnect_reports_do_not_stack_up_recoveries(
440
+ async def test_a_report_for_an_account_already_recovering_is_not_checked_again(
402
441
  auth: AuthManager,
403
- monitors: Monitors,
404
442
  protocol: StubProtocol,
405
- clock: ManualClock,
406
443
  publisher: RecordingPublisher,
444
+ sessions: SessionStore,
445
+ clock: ManualClock,
407
446
  ) -> None:
408
- await start_with_account(auth, protocol, clock, expires_in=PLENTY_OF_TIME)
447
+ """Once a drop is confirmed, further reports say nothing the queue does not.
409
448
 
410
- monitors.recovery.handle_account_disconnect(factories.ACCOUNT_ID)
411
- monitors.recovery.handle_account_disconnect(factories.ACCOUNT_ID)
412
- monitors.recovery.handle_account_disconnect(factories.ACCOUNT_ID)
413
- await publisher.wait_for_type(ReadyEvent, count=2)
414
- await clock.wait_for_sleepers(SLEEPERS_WHEN_STARTED)
449
+ The monitor is not served here: recovery's own retries would be
450
+ indistinguishable from a second check of the same report.
451
+ """
452
+ recovery = SessionRecovery(store=sessions, authenticator=auth, publisher=publisher, clock=clock)
453
+ protocol.respond_in_sequence(
454
+ ProtoOAAccountAuthReq,
455
+ [factories.account_auth_res(), APIError(error_code="TEMPORARY")],
456
+ )
457
+ await auth.authenticate_trader(factories.credentials(expires_in=PLENTY_OF_TIME))
458
+
459
+ await recovery.handle_account_disconnect(factories.ACCOUNT_ID)
460
+ checked = len(protocol.sent_of(ProtoOAAccountAuthReq))
461
+ await recovery.handle_account_disconnect(factories.ACCOUNT_ID)
462
+ await recovery.handle_account_disconnect(factories.ACCOUNT_ID)
415
463
 
416
- assert len(protocol.sent_of(ProtoOAAccountAuthReq)) == 2
464
+ assert len(protocol.sent_of(ProtoOAAccountAuthReq)) == checked
465
+ assert publisher.of(AccountDisconnectEvent) == [AccountDisconnectEvent(factories.ACCOUNT_ID)]
@@ -542,16 +542,17 @@ async def test_a_client_disconnect_carries_its_reason(
542
542
  assert received.only.reason == "Maintenance"
543
543
 
544
544
 
545
- async def test_an_account_disconnect_names_the_account(
545
+ async def test_an_account_disconnect_is_not_published_by_the_router(
546
546
  routing: EventEmitter,
547
547
  protocol: StubProtocol,
548
548
  ) -> None:
549
+ """The report is unverified here; recovery publishes the ones that hold up."""
549
550
  received: Recorder[AccountDisconnectEvent] = Recorder()
550
551
  routing.subscribe(AccountDisconnectEvent, received)
551
552
 
552
553
  await protocol.emit(ProtoOAAccountDisconnectEvent(ctid_trader_account_id=factories.ACCOUNT_ID))
553
554
 
554
- assert received.only.account_id == factories.ACCOUNT_ID
555
+ assert received.count == 0
555
556
 
556
557
 
557
558
  @pytest.mark.usefixtures("routing")
@@ -17,6 +17,7 @@ from ctrader_api_client._internal.proto import (
17
17
  )
18
18
  from ctrader_api_client.auth import AccountCredentials
19
19
  from ctrader_api_client.events import (
20
+ AccountDisconnectEvent,
20
21
  ReadyEvent,
21
22
  ReconnectedEvent,
22
23
  SpotEvent,
@@ -49,6 +50,14 @@ def rejecting(_request: betterproto.Message) -> betterproto.Message:
49
50
  return factories.error_res(error_code="ACCOUNT_NOT_AUTHORIZED")
50
51
 
51
52
 
53
+ def already_logged_in(_request: betterproto.Message) -> betterproto.Message:
54
+ """Refuse the way the server refuses an account whose session is still live."""
55
+ return factories.error_res(
56
+ error_code="ALREADY_LOGGED_IN",
57
+ description="Trading account is already authorized in this channel",
58
+ )
59
+
60
+
52
61
  # -----------------------------------------------------------------------------
53
62
  # Lifecycle
54
63
  # -----------------------------------------------------------------------------
@@ -387,15 +396,39 @@ async def test_a_dropped_account_stays_unauthorized_until_recovery_succeeds(
387
396
  server: FakeServer,
388
397
  ) -> None:
389
398
  await authenticate(client, server)
399
+ dropped: Recorder[AccountDisconnectEvent] = Recorder()
400
+ client.register_handler(AccountDisconnectEvent, dropped)
390
401
  server.on(ProtoOAAccountAuthReq, rejecting)
391
402
 
392
403
  await server.push(ProtoOAAccountDisconnectEvent(ctid_trader_account_id=factories.ACCOUNT_ID))
393
- await server.wait_for_request(ProtoOAAccountAuthReq, count=2)
404
+ # The drop is published once the server has confirmed it, which is also when
405
+ # the account stops counting as authorized.
406
+ await dropped.wait_for(1)
394
407
 
395
408
  assert client.is_account_authorized(factories.ACCOUNT_ID) is False
396
409
  assert client.is_connected is True
397
410
 
398
411
 
412
+ async def test_a_disconnect_the_server_does_not_stand_behind_is_not_published(
413
+ client: CTraderClient,
414
+ server: FakeServer,
415
+ ) -> None:
416
+ """A token rotation makes the server report a disconnect it did not perform."""
417
+ await authenticate(client, server)
418
+ dropped: Recorder[AccountDisconnectEvent] = Recorder()
419
+ ready: Recorder[ReadyEvent] = Recorder()
420
+ client.register_handler(AccountDisconnectEvent, dropped)
421
+ client.register_handler(ReadyEvent, ready)
422
+ server.on(ProtoOAAccountAuthReq, already_logged_in)
423
+
424
+ await server.push(ProtoOAAccountDisconnectEvent(ctid_trader_account_id=factories.ACCOUNT_ID))
425
+ await server.wait_for_request(ProtoOAAccountAuthReq, count=2)
426
+
427
+ assert dropped.count == 0
428
+ assert ready.count == 0
429
+ assert client.is_account_authorized(factories.ACCOUNT_ID) is True
430
+
431
+
399
432
  # -----------------------------------------------------------------------------
400
433
  # Token refresh
401
434
  # -----------------------------------------------------------------------------
@@ -133,7 +133,7 @@ class RecordingRecovery:
133
133
  def __init__(self) -> None:
134
134
  self.disconnected: list[int] = []
135
135
 
136
- def handle_account_disconnect(self, account_id: int) -> None:
136
+ async def handle_account_disconnect(self, account_id: int) -> None:
137
137
  """Record the disconnected account."""
138
138
  self.disconnected.append(account_id)
139
139
 
@@ -270,7 +270,7 @@ wheels = [
270
270
 
271
271
  [[package]]
272
272
  name = "ctrader-api-client"
273
- version = "0.7.0"
273
+ version = "0.7.1"
274
274
  source = { editable = "." }
275
275
  dependencies = [
276
276
  { name = "anyio" },
@@ -1,251 +0,0 @@
1
- # Architecture hardening
2
-
3
- Working notes for the `harden-architecture` branch. Everything below is against
4
- `master` at `8f649f2`.
5
-
6
- ## Done
7
-
8
- Each item is one commit, in order.
9
-
10
- ### Connection and auth lifecycle tests — `de41c4d`
11
-
12
- Edge cases the existing suite did not reach, added before any of the
13
- refactoring below so the refactoring had something to fail against: framing,
14
- reconnection, token lifecycle and client startup.
15
-
16
- ### Typed request correlation — `bb66230`
17
-
18
- `Protocol` tracked in-flight requests across three parallel dicts keyed by
19
- message id. They could disagree. Replaced by a single object holding the event,
20
- the outcome and the expected response type, so a pending request is one thing
21
- that either exists or does not.
22
-
23
- ### Event handler registration — `4131424`
24
-
25
- Registering a handler and unregistering it were written out separately at every
26
- call site. `on_event` now returns a disposer, and callers hold that instead of
27
- remembering how to undo their own registration.
28
-
29
- ### Pluggable token storage — `b6dc7df`
30
-
31
- Added the `TokenStore` protocol and the option to pass one to the client. The
32
- client writes through it whenever tokens rotate, so a process that restarts can
33
- pick up where it left off rather than re-authenticating from credentials.
34
-
35
- Two things moved in passing, both breaking:
36
-
37
- - `AuthTrigger` moved from `auth/trigger.py`, which is gone, to `enums.py`. It
38
- describes why a session was established, which the event types need and the
39
- auth package should not own alone.
40
- - `ReadyEvent.is_reconnect` is now a property over a `trigger: AuthTrigger`
41
- field. Reading it is unchanged; constructing a `ReadyEvent` is not.
42
-
43
- ### Dead code and a start() guard — `de21183`
44
-
45
- Removed `on_handler_error()`, which nothing called. Made
46
- `HeartbeatManager.start()` idempotent: a second call used to overwrite the
47
- nursery reference and leave the first loop unreachable, so `stop()` could never
48
- find it.
49
-
50
- ### Automatic subscription restore — `e0a3273`
51
-
52
- Subscriptions were restored by an explicit call after reconnection, which meant
53
- every reconnection path had to remember to make it. `MarketDataAPI` is now
54
- registered as a restorer and the reconnect path drives it.
55
-
56
- ### TokenStore documentation — `2f17ac3`
57
-
58
- No code change. `TokenStore` is write-only by design — the library never reads
59
- it back, because only the caller knows which accounts a given process is
60
- responsible for. The name suggested otherwise, so the docstrings, the README
61
- example and `docs/api/client.md` now say so and show the startup branch the
62
- caller is expected to write.
63
-
64
- Deliberately **not** done: adding `load()` to the protocol. The library would
65
- have no reason to call it.
66
-
67
- ### Composition root — `b3e6fb5`
68
-
69
- `CTraderClient.__init__` built eleven collaborators and wired them together,
70
- which made the client impossible to construct with a substitute for any one of
71
- them. That wiring moved verbatim to `composition.py` as `build_graph()`
72
- returning a frozen `ClientGraph`, and `CTraderClient.from_graph()` adopts one.
73
-
74
- Two ordering constraints in the wiring are load-bearing and are noted in the
75
- source: market data is built before auth because it is the restorer, and auth
76
- before the router because it is the recovery.
77
-
78
- Deliberately **not** done: a `build_client()` convenience wrapper.
79
- `build_graph` plus `from_graph` already covers it.
80
-
81
- ### Lifecycle inversion — `bd92f44` — **breaking**
82
-
83
- `Protocol`, `HeartbeatManager` and `AuthManager` each held a task group handed
84
- to them by a `start()` call and exited by a later `stop()` call from a different
85
- task. `start()` replaced by `serve()`, which owns its nursery lexically and
86
- suspends inside it; `stop()` reduced to cancelling the scope, which is allowed
87
- from any task.
88
-
89
- `CTraderClient.connect()` and `.close()` are **gone**. `async with client:` is
90
- the only way to bring a client up. The nursery lives in the `async with` block,
91
- so a background loop that dies is raised out of it rather than discovered at
92
- shutdown.
93
-
94
- Three things fell out of this:
95
-
96
- - Every `except Exception: pass` in a `stop()` is gone; there is nothing left
97
- to swallow.
98
- - Cleanup moved into each `serve()`'s `finally`, so cancellation now cleans up
99
- as thoroughly as an orderly stop.
100
- - `task_group.start` rather than `start_soon` in the client means the reader is
101
- provably live before anything is sent. That ordering used to be implicit.
102
-
103
- Test-side, `connected` and `serving` fixtures give the *test* ownership of the
104
- task group, for the same reason: a loop that dies mid-test surfaces at the end
105
- of it instead of vanishing.
106
-
107
- ### B1 — connection supervisor — `9df080b` — **breaking**
108
-
109
- `Protocol` reported drops and recoveries through two callbacks the client set
110
- on it by hand, and `CTraderClient._lifecycle` knew the order transport,
111
- protocol, heartbeat and auth had to come up in. Both are gone.
112
-
113
- `ConnectionSupervisor` owns the transport, the protocol and the heartbeat.
114
- `serving()` is an async context manager rather than a `serve()`: connecting
115
- happens before the block is entered, so a server that is not there still raises
116
- `CTraderConnectionFailedError` instead of the `ExceptionGroup` a task group
117
- would make of it. `_lifecycle` is now the supervisor's block with auth and the
118
- router inside it.
119
-
120
- The callbacks became one port. `ConnectionListener` — `on_connection_lost` and
121
- `on_connection_restored` — is what `Protocol` reports to and what the
122
- supervisor fans out to. The supervisor puts the heartbeat back itself before
123
- notifying, because that is its own machinery rather than a listener's concern.
124
-
125
- The listener is `AuthManager`, not the client. Re-authenticating the
126
- application and every account after a reconnect, and reporting the outcome as
127
- `ReconnectedEvent`, is session work; it only lived on the client because the
128
- callback did. That takes ~70 lines off `client.py`, and puts the logic where
129
- B3's `SessionRecovery` would want it.
130
-
131
- Breaking: `Protocol.set_reconnect_handler` and `set_disconnect_handler` are
132
- replaced by `Protocol.set_listener`, and `AuthManager.handle_connection_lost`
133
- is now the private `_forget_sessions`, reached through the port.
134
-
135
- Test-side, a `make_graph` fixture hands tests the graph before the client
136
- adopts it, which is how the listener fan-out is exercised.
137
-
138
- ### Exception shape at the client boundary — `ff6137a`
139
-
140
- Found while placing automatic application authentication. Since the lifecycle
141
- inversion, the task group holding the background tasks surrounds the `yield` in
142
- `_lifecycle`, so *every* exception leaving `async with client:` passed through
143
- it — and anyio wraps a lone exception as readily as a group. A caller's own
144
- `ValueError` came back as `ExceptionGroup: unhandled errors in a TaskGroup (1
145
- sub-exception)`, and `except CTraderConnectionFailedError` would have stopped
146
- matching anything the machinery raised.
147
-
148
- `__aenter__` and `__aexit__` now peel single-exception groups, recursively,
149
- since the supervisor's task group nests inside the client's. Several failures
150
- still arrive as a group, because that is what they are, and the block's own
151
- exception is returned to the caller untouched rather than re-raised.
152
-
153
- Not a break against `master`: this restores the shape callers had before the
154
- branch.
155
-
156
- ### B3 — split AuthManager — `651da82` — **breaking**
157
-
158
- `auth/manager.py` was 751 lines doing four unrelated jobs. It is now four
159
- pieces, each with one:
160
-
161
- - `auth/_session.py` — `SessionStore`, joining the session state types already
162
- there. It owns the dict, its invalidation and every query over it, so the
163
- distinction between credentials (which outlive a link) and sessions (which do
164
- not) is stated once instead of being re-derived by each collaborator.
165
- - `auth/_refresh.py` — `TokenRefresher`, the refresh loop, its retry policy and
166
- the `TokenStore` write-through.
167
- - `auth/_recovery.py` — `SessionRecovery`, the reauth loop and the
168
- `ConnectionListener` methods. It is also the `SessionRecovery` port the event
169
- router already typed its collaborator as, so the names finally line up.
170
- - `auth/manager.py` — `AuthManager`, down to turning credentials into live
171
- sessions.
172
-
173
- The refresher and the recovery monitor each serve themselves, so `_lifecycle`
174
- starts two tasks where it started one, and `AuthManager` no longer has a
175
- lifecycle of its own.
176
-
177
- The public surface was cut at the same time, on the rule that nothing returning
178
- a bare `ProtoOA` message should be public:
179
-
180
- - `authenticate_app` is called by the client as it connects, and on every
181
- reconnect. A client that cannot authenticate does not open, and the failure
182
- arrives as `ApplicationAuthError` thanks to the unwrapping above.
183
- - `authenticate_account` became `authenticate_trader`, returning nothing. The
184
- `trigger` argument moved to the internal `establish`, where the refresher and
185
- the recovery monitor use it.
186
- - `get_accounts` and `resolve_account_id` moved to `AccountsAPI` as
187
- `list_by_token` and `resolve_account_id`. They are account queries, not auth.
188
- - `authenticate_by_trader_login` is gone with no replacement. Resolving a login
189
- and authenticating an account are two steps, and the convenience of joining
190
- them cost a second way to do the same thing:
191
- `await client.auth.authenticate_trader(await store.load(account_id))` is now
192
- the only shape, restart or not.
193
-
194
- `APIError.is_token_failure()` replaces the module-level helper, since three
195
- modules now need to ask.
196
-
197
- ### Flat public surface — `6cc397c`
198
-
199
- The package exported `CTraderClient`, `ClientConfig` and seven of the fourteen
200
- enums "for easier access". The seven were an arbitrary subset: a caller reading
201
- `order.status`, `order.time_in_force`, `deal.status`, `symbol.trading_mode` or
202
- `account.access_rights` had to switch to `ctrader_api_client.enums` halfway down
203
- the file. `AccountCredentials`, the one type nobody can avoid constructing, sat
204
- two modules deep.
205
-
206
- The top level now carries what a caller names — what you construct, what you
207
- receive, what you catch — and the subpackages keep what the composition root
208
- wires. Sixty-eight names: the client and its config, the credentials and the two
209
- policies and the `TokenStore` protocol, every enum, every model, every event
210
- type, every exception. `AuthManager`, `SessionStore`, `TokenRefresher`,
211
- `SessionRecovery`, the emitter/publisher/router and everything under `api/`,
212
- `connection/` and `_internal/` stay where they are, since the client hands them
213
- to you already assembled.
214
-
215
- The result models are exported despite `Account`, `Order`, `Position`, `Symbol`
216
- and `Event` being names a trading codebase may well use itself. A name you
217
- receive is a name you annotate, and `import ctrader_api_client as ct` is there
218
- for anyone with a conflict.
219
-
220
- Additive: the subpackage paths still work. Importing costs nothing extra either,
221
- since `from .client import CTraderClient` already pulled the whole graph in.
222
-
223
- The `Environment` enum was deleted in passing — declared, documented, and
224
- referenced nowhere. `ClientConfig` takes a `host` string.
225
-
226
- ## Open
227
-
228
- ### Version bump and changelog
229
-
230
- The lifecycle inversion removes `connect()` and `close()` from the public API,
231
- and the connection supervisor replaces `Protocol.set_reconnect_handler` /
232
- `set_disconnect_handler` with `set_listener` and drops
233
- `AuthManager.handle_connection_lost`. The AuthManager split then removes
234
- `authenticate_app`, `authenticate_account`, `authenticate_by_trader_login`,
235
- `get_accounts` and `resolve_account_id` from `client.auth`, adding
236
- `authenticate_trader` in their place and moving the two queries to
237
- `client.accounts`. The `TokenStore` commit moved `AuthTrigger` out of
238
- `auth.trigger` into `enums`, and turned `ReadyEvent.is_reconnect` into a
239
- property over a new `trigger` field, which breaks anyone constructing the event
240
- themselves. This needs a bump from `0.6.0` and a note saying so.
241
-
242
- The flat surface is additive and breaks nobody, but the `Environment` enum it
243
- deleted was public, so it belongs in the same note. Nothing else on the branch
244
- breaks a caller.
245
-
246
- ### Smaller things noticed but not acted on
247
-
248
- - `client.py` is down to 501 lines, nearly all delegation and docstrings.
249
- - `protocol.py` is 550 lines and does framing, correlation, dispatch and
250
- reconnection. Splitting it was never discussed; noting it as an observation
251
- rather than a proposal.