ctrader-api-client 0.10.0__tar.gz → 0.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/PKG-INFO +4 -1
  2. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/README.md +3 -0
  3. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/api/client.md +3 -1
  4. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/api/exceptions.md +6 -4
  5. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/getting-started.md +6 -0
  6. ctrader_api_client-0.11.0/plans/startup-token-refresh.md +157 -0
  7. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/pyproject.toml +1 -1
  8. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/auth/_maintain.py +5 -40
  9. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/auth/manager.py +66 -12
  10. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/composition.py +1 -2
  11. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/exceptions.py +2 -2
  12. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/auth/conftest.py +16 -8
  13. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/auth/test_authentication.py +41 -2
  14. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/auth/test_token_lifecycle.py +34 -14
  15. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/uv.lock +1 -1
  16. ctrader_api_client-0.10.0/plans/connection-auth-refactor.md +0 -99
  17. ctrader_api_client-0.10.0/plans/connection-research.md +0 -43
  18. ctrader_api_client-0.10.0/plans/strict-enum-mapping.md +0 -65
  19. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/.claude/settings.local.json +0 -0
  20. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/.complexipy_cache/.gitignore +0 -0
  21. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/.complexipy_cache/CACHEDIR.TAG +0 -0
  22. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/.complexipy_cache/README.md +0 -0
  23. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/.complexipy_cache/v/cache/functions +0 -0
  24. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/.github/workflows/docs.yml +0 -0
  25. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/.gitignore +0 -0
  26. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/.pre-commit-config.yaml +0 -0
  27. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/.python-version +0 -0
  28. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/Justfile +0 -0
  29. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/LICENSE +0 -0
  30. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/api/accounts.md +0 -0
  31. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/api/enums.md +0 -0
  32. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/api/events.md +0 -0
  33. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/api/market-data.md +0 -0
  34. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/api/models.md +0 -0
  35. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/api/symbols.md +0 -0
  36. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/api/trading.md +0 -0
  37. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/docs/index.md +0 -0
  38. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/mkdocs.yml +0 -0
  39. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/protos/SOURCE +0 -0
  40. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/protos/VERSION +0 -0
  41. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/protos/update.sh +0 -0
  42. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/protos/vendor/OpenApiCommonMessages.proto +0 -0
  43. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
  44. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/protos/vendor/OpenApiMessages.proto +0 -0
  45. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/protos/vendor/OpenApiModelMessages.proto +0 -0
  46. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/scripts/fix_proto_imports.py +0 -0
  47. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/__init__.py +0 -0
  48. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/__init__.py +0 -0
  49. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/backoff.py +0 -0
  50. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/clock.py +0 -0
  51. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/conversions.py +0 -0
  52. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/messages.py +0 -0
  53. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
  54. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
  55. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
  56. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
  57. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
  58. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/_internal/serialization.py +0 -0
  59. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/api/__init__.py +0 -0
  60. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/api/_base.py +0 -0
  61. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/api/accounts.py +0 -0
  62. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/api/market_data.py +0 -0
  63. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/api/symbols.py +0 -0
  64. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/api/trading.py +0 -0
  65. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/auth/__init__.py +0 -0
  66. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/auth/_session.py +0 -0
  67. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/auth/credentials.py +0 -0
  68. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/auth/policy.py +0 -0
  69. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/auth/store.py +0 -0
  70. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/client.py +0 -0
  71. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/config.py +0 -0
  72. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/connection/__init__.py +0 -0
  73. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/connection/heartbeat.py +0 -0
  74. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/connection/protocol.py +0 -0
  75. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/connection/supervisor.py +0 -0
  76. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/connection/transport.py +0 -0
  77. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/enums.py +0 -0
  78. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/events/__init__.py +0 -0
  79. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/events/_execution.py +0 -0
  80. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/events/emitter.py +0 -0
  81. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/events/publisher.py +0 -0
  82. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/events/router.py +0 -0
  83. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/events/types.py +0 -0
  84. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/__init__.py +0 -0
  85. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/_base.py +0 -0
  86. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/account.py +0 -0
  87. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/commission.py +0 -0
  88. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/deal.py +0 -0
  89. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/market_data.py +0 -0
  90. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/order.py +0 -0
  91. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/position.py +0 -0
  92. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/requests.py +0 -0
  93. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/symbol.py +0 -0
  94. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/models/trading_hours.py +0 -0
  95. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/src/ctrader_api_client/py.typed +0 -0
  96. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/__init__.py +0 -0
  97. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/__init__.py +0 -0
  98. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/api/__init__.py +0 -0
  99. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/api/conftest.py +0 -0
  100. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/api/test_market_data.py +0 -0
  101. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/api/test_reference_data.py +0 -0
  102. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/api/test_subscription_restore.py +0 -0
  103. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/api/test_trading.py +0 -0
  104. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/auth/__init__.py +0 -0
  105. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/connection/__init__.py +0 -0
  106. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/connection/conftest.py +0 -0
  107. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/connection/test_event_dispatch.py +0 -0
  108. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/connection/test_framing.py +0 -0
  109. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/connection/test_heartbeat.py +0 -0
  110. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/connection/test_reconnection.py +0 -0
  111. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/connection/test_request_response.py +0 -0
  112. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/events/__init__.py +0 -0
  113. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/events/conftest.py +0 -0
  114. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/events/test_routing.py +0 -0
  115. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/events/test_subscriptions.py +0 -0
  116. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/behavior/test_client.py +0 -0
  117. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/conftest.py +0 -0
  118. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/harness/__init__.py +0 -0
  119. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/harness/clock.py +0 -0
  120. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/harness/factories.py +0 -0
  121. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/harness/recorder.py +0 -0
  122. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/harness/server.py +0 -0
  123. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/harness/signals.py +0 -0
  124. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/harness/stub_protocol.py +0 -0
  125. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/harness/wire.py +0 -0
  126. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/unit/__init__.py +0 -0
  127. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/unit/test_api_errors.py +0 -0
  128. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/unit/test_commission_conversion.py +0 -0
  129. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/unit/test_enum_mapping.py +0 -0
  130. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/unit/test_market_data_conversion.py +0 -0
  131. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/unit/test_request_conversion.py +0 -0
  132. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/unit/test_symbol_conversion.py +0 -0
  133. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/unit/test_trading_hours.py +0 -0
  134. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/unit/test_trading_records.py +0 -0
  135. {ctrader_api_client-0.10.0 → ctrader_api_client-0.11.0}/tests/unit/test_wire_format.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: ctrader-api-client
3
- Version: 0.10.0
3
+ Version: 0.11.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
@@ -133,6 +133,9 @@ await client.auth.authenticate_trader(
133
133
  # Tokens are automatically refreshed before expiry. A refresh that fails is
134
134
  # retried on the next check rather than dropping the session, and surfaces as a
135
135
  # TokenRefreshFailedEvent so a persistently dead refresh token is observable.
136
+ # An access token that has already expired is refreshed before the account is
137
+ # authenticated, so a process that starts after the expiry needs no new token.
138
+ # Only a refresh token the server refuses raises TokenExpiredError.
136
139
  ```
137
140
 
138
141
  ### Persisting Rotated Tokens
@@ -114,6 +114,9 @@ await client.auth.authenticate_trader(
114
114
  # Tokens are automatically refreshed before expiry. A refresh that fails is
115
115
  # retried on the next check rather than dropping the session, and surfaces as a
116
116
  # TokenRefreshFailedEvent so a persistently dead refresh token is observable.
117
+ # An access token that has already expired is refreshed before the account is
118
+ # authenticated, so a process that starts after the expiry needs no new token.
119
+ # Only a refresh token the server refuses raises TokenExpiredError.
117
120
  ```
118
121
 
119
122
  ### Persisting Rotated Tokens
@@ -70,7 +70,9 @@ Token-refresh and session-recovery timing, passed via
70
70
 
71
71
  The `client.auth` property provides access to authentication operations. The
72
72
  application is authenticated as the client connects, so what is left here is
73
- authenticating trading accounts and asking after the sessions they hold.
73
+ authenticating trading accounts and asking after the sessions they hold. An
74
+ account whose access token has expired is refreshed before it is authenticated,
75
+ so the refresh token in the credentials is what a restart depends on.
74
76
 
75
77
  ::: ctrader_api_client.auth.AuthManager
76
78
  options:
@@ -19,7 +19,7 @@ CTraderError
19
19
  ├── AuthenticationError
20
20
  │ ├── ApplicationAuthError the server rejected client_id/secret
21
21
  │ ├── AccountAuthError the server rejected the account
22
- │ ├── TokenExpiredError the access token is expired or invalid
22
+ │ ├── TokenExpiredError the server refused the access or refresh token
23
23
  │ ├── TokenRefreshError a refresh exhausted its retries
24
24
  │ └── AccountNotFoundError no account matches that trader login
25
25
  ├── APIError an error response to a request
@@ -69,9 +69,11 @@ except AccountAuthError as e:
69
69
  print(f"Account {e.ctid_trader_account_id} rejected: {e.error_code}")
70
70
  ```
71
71
 
72
- `TokenExpiredError` covers both a token the client knows has expired and one the
73
- server rejects as invalid. Neither can be retried back to life — it needs a new
74
- token.
72
+ `TokenExpiredError` means the server refused a token: the access token at
73
+ authentication, or the refresh token when the client refreshed an expired access
74
+ token first. Neither can be retried back to life. It needs a new OAuth
75
+ authorization. An access token that has merely expired does not raise, because
76
+ the client refreshes it before it authenticates the account.
75
77
 
76
78
  ### Requests
77
79
 
@@ -235,6 +235,12 @@ The contract is write-only. Reading back at startup is yours, since only you kno
235
235
  which accounts a given process is responsible for. See
236
236
  [TokenStore](api/client.md#tokenstore) for the full contract.
237
237
 
238
+ A stored pair whose access token has expired still works. `authenticate_trader`
239
+ refreshes it first and saves the new pair through the store, so a process that
240
+ starts after the expiry needs no new token from the outside. Only a refresh token
241
+ that the server refuses raises `TokenExpiredError`, and that needs a new OAuth
242
+ authorization.
243
+
238
244
  ## Handling Errors
239
245
 
240
246
  Everything the library raises derives from `CTraderError`. Bringing the client up
@@ -0,0 +1,157 @@
1
+ # Startup token refresh
2
+
3
+ Status: complete on 2026-09-27 in 0.11.0, commits `ee431d9`, `de5037f`,
4
+ `5869d89`, `92c2f79`, and `ec6d29f`. The consumer follow-up is pending.
5
+
6
+ ## Problem
7
+
8
+ An expired access token is terminal, although the credentials that hold it also
9
+ hold a refresh token that would replace it.
10
+
11
+ - `AuthManager.establish` raises `TokenExpiredError` when `credentials.is_expired()`
12
+ is true. It sends no request first.
13
+ - The refresh exchange lives only in `SessionMaintainer._rotate`. Only the refresh
14
+ loop reaches it, and the loop runs only while the client is open.
15
+
16
+ So a token that expires while no client is open is dead on the next start. This
17
+ hits every process that is not a long-lived daemon. A daily job that runs for
18
+ minutes never sits in the five-minute refresh window, so its token always expires
19
+ between runs. A long-lived process hits the same wall after a link outage that
20
+ spans the expiry: `prepare` re-establishes each account with the expired token and
21
+ fails.
22
+
23
+ ## Facts from the source
24
+
25
+ - `auth/manager.py`: `establish` guards with `is_expired()` before the
26
+ `ProtoOAAccountAuthReq`. `authenticate_trader`, `SessionMaintainer.prepare`,
27
+ `SessionMaintainer._recover`, and `SessionMaintainer._rotate` all call it.
28
+ - `auth/_maintain.py`: `_rotate` sends `ProtoOARefreshTokenReq`, maps a token
29
+ failure to `TokenExpiredError`, calls `manager.replace_credentials`, saves through
30
+ the token store, then calls `establish` with `AuthTrigger.TOKEN_REFRESH`. The
31
+ new pair goes into memory before the save, because the server has invalidated
32
+ the old pair.
33
+ - `_refresh` wraps `_rotate` with the retry policy and the refresh window
34
+ (`_refreshing`). The window exists so that the disconnect report the server sends
35
+ for the old authorization is not read as a drop.
36
+ - `AuthManager.replace_credentials` creates a `TrackedAccount` with no session for
37
+ an account id it does not hold.
38
+ - `tests/behavior/auth/test_token_lifecycle.py` pins the refresh loop: the request
39
+ carries the current refresh token, the new pair is saved, a dead refresh token is
40
+ not retried, a store that cannot save aborts the refresh, and a check refused over
41
+ the token leaves the account alone.
42
+ - `tests/behavior/auth/test_authentication.py::test_an_already_expired_token_is_not_sent_to_the_server`
43
+ pins the current terminal behaviour.
44
+ - `RefreshPolicy.buffer_seconds` drives the loop only.
45
+
46
+ ## Design
47
+
48
+ 1. `AuthManager` gains `refresh(credentials, timeout=None) -> AccountCredentials`.
49
+ It sends the refresh request, maps a token failure to `TokenExpiredError`,
50
+ calls `replace_credentials`, and saves through the token store. This is the
51
+ body of `_rotate` today, moved. The manager takes `token_store`; the
52
+ maintainer no longer does.
53
+ 2. `establish` replaces the guard `if is_expired: raise` with
54
+ `credentials = await self.refresh(credentials)`. Every path that establishes a
55
+ session gets the behaviour from the one place that owned the decision. No
56
+ caller changes.
57
+ 3. `SessionMaintainer._rotate` becomes: `rotated = await manager.refresh(c)`, then
58
+ `establish(rotated, TOKEN_REFRESH)`. The retry policy, the refresh window, and
59
+ the `TokenRefreshFailedEvent` stay in the maintainer. The maintainer drops the
60
+ token store and the proto imports.
61
+ 4. `replace_credentials` does nothing for an account id it does not hold. The
62
+ name promises a replacement, and an account tracked before it ever
63
+ authenticated is a latent bug: `prepare` would try to re-establish it.
64
+ 5. The library ships no `TokenStore` implementation. The protocol stays
65
+ write-only, and each consumer keeps its own store, because only the consumer
66
+ knows which accounts a process holds and where they live. A shipped file
67
+ store was in an earlier version of this plan and was dropped on 2026-09-27:
68
+ only one consumer wants a file, and a `load_all` on a shipped store would
69
+ assert a one-file-one-process model that the library has no business
70
+ asserting.
71
+
72
+ ### What does not change
73
+
74
+ - `refresh` makes one attempt. A refused refresh token raises `TokenExpiredError`.
75
+ Another refusal raises the `APIError`. A timeout propagates. The maintainer keeps
76
+ its retry-and-report wrapping around the same call.
77
+ - The new pair goes into memory before the save. A store that cannot save raises,
78
+ and the account is not authenticated, because a pair the store never accepted
79
+ strands the next restart. This is the stance the lifecycle tests pin today.
80
+ - No refresh window at the three new refresh points. No live authorization exists
81
+ there: the account is not tracked yet, or `prepare` invalidated it, or it awaits
82
+ recovery. The existing test that a check refused over the token leaves the
83
+ account alone covers the case where a report arrives anyway.
84
+ - `_recover` defers on any exception, with no attempt limit. A dead refresh token
85
+ is retried the same way an expired token is today.
86
+ - `buffer_seconds` plays no part at startup. Only `is_expired` triggers a refresh
87
+ in `establish`. A token with time left is established as today, and the loop
88
+ handles "expires soon" with its retries.
89
+
90
+ ### Attribute counts
91
+
92
+ - `AuthManager`: 7 today, 8 after (`_token_store`).
93
+ - `SessionMaintainer`: 9 today, 7 after (`_token_store` leaves, and `_protocol`
94
+ leaves with it, because the refresh exchange was its only use). Agreed during
95
+ step 2.
96
+
97
+ ## Steps
98
+
99
+ One step per turn. Each step leaves the suite green.
100
+
101
+ ### Step 1: `replace_credentials` ignores an unknown account
102
+
103
+ `auth/manager.py` only. One new test in `test_authentication.py`: replacing the
104
+ credentials of an account the manager does not hold leaves the manager without
105
+ that account.
106
+
107
+ ### Step 2: move the refresh exchange to the manager
108
+
109
+ - `AuthManager.__init__` takes `token_store: TokenStore | None = None`.
110
+ - Add `AuthManager.refresh`.
111
+ - `SessionMaintainer.__init__` drops `token_store`. `_rotate` calls
112
+ `manager.refresh`. Remove the proto imports and `TokenStore` import from
113
+ `_maintain.py`.
114
+ - `composition.build_graph` passes `token_store` to `AuthManager`.
115
+ - `tests/behavior/auth/conftest.py`: a `store` fixture returns a `RecordingStore`.
116
+ `make_auth` passes it as `token_store`. `make_maintainer` accepts a `manager`
117
+ override for a test that builds its own manager. The tests that construct a
118
+ `RecordingStore` build the manager with it through `make_auth` instead of
119
+ passing it to `make_maintainer`.
120
+
121
+ Every assertion in `test_token_lifecycle.py` passes unchanged.
122
+
123
+ ### Step 3: `establish` refreshes an expired token
124
+
125
+ - `auth/manager.py`: the guard becomes the refresh call.
126
+ - Replace `test_an_already_expired_token_is_not_sent_to_the_server` in
127
+ `test_authentication.py` with:
128
+ 1. An expired token is refreshed before the account is authenticated: the auth
129
+ request carries the new token, the store saved it, and the expired token was
130
+ not sent.
131
+ 2. An expired token whose refresh is refused raises `TokenExpiredError`, and no
132
+ auth request is sent.
133
+ 3. A token with time left is not refreshed at authentication.
134
+ - Add to `test_token_lifecycle.py`:
135
+ 4. A new link refreshes an expired account before it re-establishes it:
136
+ `maintainer.prepare()` with an expired tracked account sends the refresh
137
+ request, then the auth request with the new token.
138
+
139
+ ### Step 4: docs and version
140
+
141
+ - `README.md` and `docs/getting-started.md`: state that `authenticate_trader`
142
+ refreshes an expired access token, and what it raises. The file store example
143
+ in the getting-started guide stays as consumer code.
144
+ - `docs/api/exceptions.md`: `TokenExpiredError` now means a token the server
145
+ refused. A token the client knows has expired is refreshed instead. Added
146
+ during step 3.
147
+ - `pyproject.toml`: 0.11.0.
148
+
149
+ ## Consumer follow-up
150
+
151
+ Out of scope for this repository. Recorded so the two changes stay aligned.
152
+
153
+ The framework bumps its pin to 0.11.0 and adds its own file-backed `TokenStore`
154
+ with a `CTRADER_TOKEN_FILE` setting, default `.ctrader-tokens.json`. It saves
155
+ the bootstrap pair from `.env` on the first run after it resolves the account
156
+ id, and reads the stored pair back on later runs. A publish deployment has one
157
+ broker account, so a file with more than one entry is an error.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "ctrader-api-client"
3
- version = "0.10.0"
3
+ version = "0.11.0"
4
4
  description = "API Client to interact with the cTrader Open API spec"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -9,13 +9,11 @@ that the server drops, and prepares each new link.
9
9
  from __future__ import annotations
10
10
 
11
11
  import logging
12
- from typing import TYPE_CHECKING
13
12
 
14
13
  import anyio
15
14
  import anyio.abc
16
15
 
17
16
  from .._internal import Clock, MonotonicClock, backoff_delay
18
- from .._internal.proto import ProtoOARefreshTokenReq, ProtoOARefreshTokenRes
19
17
  from ..enums import AuthTrigger
20
18
  from ..events import (
21
19
  AccountDisconnectEvent,
@@ -24,16 +22,11 @@ from ..events import (
24
22
  ReconnectedEvent,
25
23
  TokenRefreshFailedEvent,
26
24
  )
27
- from ..exceptions import AccountAuthError, APIError, TokenExpiredError, TokenRefreshError
25
+ from ..exceptions import AccountAuthError, APIError, TokenRefreshError
28
26
  from ._session import AwaitingRecovery
29
27
  from .credentials import AccountCredentials
30
28
  from .manager import AuthManager
31
29
  from .policy import ReauthPolicy, RefreshPolicy
32
- from .store import TokenStore
33
-
34
-
35
- if TYPE_CHECKING:
36
- from ..connection.protocol import Protocol
37
30
 
38
31
 
39
32
  logger = logging.getLogger(__name__)
@@ -66,34 +59,27 @@ class SessionMaintainer:
66
59
 
67
60
  def __init__(
68
61
  self,
69
- protocol: Protocol,
70
62
  manager: AuthManager,
71
63
  publisher: EventPublisher,
72
64
  refresh_policy: RefreshPolicy | None = None,
73
65
  reauth_policy: ReauthPolicy | None = None,
74
66
  clock: Clock | None = None,
75
- token_store: TokenStore | None = None,
76
67
  ) -> None:
77
68
  """Initialize the maintainer.
78
69
 
79
70
  Args:
80
- protocol: The protocol that sends the refresh requests.
81
- manager: Holds the accounts, and authenticates the application and
82
- the accounts.
71
+ manager: Holds the accounts, refreshes their tokens, and
72
+ authenticates the application and the accounts.
83
73
  publisher: Where the events are published.
84
74
  refresh_policy: When to refresh access tokens, and how hard to try.
85
75
  reauth_policy: Backoff for the sessions that the server dropped.
86
76
  clock: Time source for the refresh interval and the backoff.
87
- token_store: Durable storage for rotated credentials. Without one,
88
- refreshed tokens live only in memory.
89
77
  """
90
- self._protocol = protocol
91
78
  self._manager = manager
92
79
  self._publisher = publisher
93
80
  self._refresh_policy = refresh_policy if refresh_policy is not None else RefreshPolicy()
94
81
  self._reauth_policy = reauth_policy if reauth_policy is not None else ReauthPolicy()
95
82
  self._clock = clock if clock is not None else MonotonicClock()
96
- self._token_store = token_store
97
83
 
98
84
  # Set when an account needs recovery. The recovery loop replaces it
99
85
  # each time it wakes.
@@ -307,32 +293,11 @@ class SessionMaintainer:
307
293
  settled.set()
308
294
 
309
295
  async def _rotate(self, credentials: AccountCredentials) -> None:
310
- """Make one refresh exchange, keep the new pair, and re-authorize the account.
311
-
312
- The server invalidates the old pair as it issues the new one, so the
313
- new pair is kept before the store saves it.
296
+ """Make one refresh exchange and re-authorize the account with the new pair.
314
297
 
315
298
  Raises:
316
299
  TokenExpiredError: If the refresh token is dead.
317
300
  APIError: If the server refuses the request for another reason.
318
301
  """
319
- request = ProtoOARefreshTokenReq(refresh_token=credentials.refresh_token)
320
- try:
321
- response = await self._protocol.request(request, ProtoOARefreshTokenRes)
322
- except APIError as e:
323
- if e.is_token_failure():
324
- raise TokenExpiredError(credentials.account_id) from e
325
- raise
326
-
327
- rotated = credentials.with_refreshed_tokens(
328
- access_token=response.access_token,
329
- refresh_token=response.refresh_token,
330
- expires_in=response.expires_in,
331
- )
332
- self._manager.replace_credentials(rotated)
333
-
334
- if self._token_store is not None:
335
- await self._token_store.save(rotated)
336
-
337
- logger.debug("Token refreshed for account %d, new expiry in %ds", credentials.account_id, response.expires_in)
302
+ rotated = await self._manager.refresh(credentials)
338
303
  await self._manager.establish(rotated, AuthTrigger.TOKEN_REFRESH)
@@ -15,6 +15,8 @@ from .._internal.proto import (
15
15
  ProtoOAAccountAuthRes,
16
16
  ProtoOAApplicationAuthReq,
17
17
  ProtoOAApplicationAuthRes,
18
+ ProtoOARefreshTokenReq,
19
+ ProtoOARefreshTokenRes,
18
20
  ProtoOATraderReq,
19
21
  ProtoOATraderRes,
20
22
  )
@@ -28,6 +30,7 @@ from ..exceptions import (
28
30
  )
29
31
  from ._session import Authorized, AwaitingRecovery, TrackedAccount
30
32
  from .credentials import AccountCredentials
33
+ from .store import TokenStore
31
34
 
32
35
 
33
36
  if TYPE_CHECKING:
@@ -75,6 +78,7 @@ class AuthManager:
75
78
  client_id: str,
76
79
  client_secret: str,
77
80
  restorer: SessionRestorer | None = None,
81
+ token_store: TokenStore | None = None,
78
82
  ) -> None:
79
83
  """Initialize the authentication manager.
80
84
 
@@ -85,12 +89,15 @@ class AuthManager:
85
89
  client_secret: OAuth client secret for the application.
86
90
  restorer: Re-applies whatever a fresh session starts without, before
87
91
  the account is announced as ready.
92
+ token_store: Durable storage for rotated credentials. Without one,
93
+ refreshed tokens live only in memory.
88
94
  """
89
95
  self._protocol = protocol
90
96
  self._publisher = publisher
91
97
  self._client_id = client_id
92
98
  self._client_secret = client_secret
93
99
  self._restorer = restorer
100
+ self._token_store = token_store
94
101
  self._app_authenticated = False
95
102
  self._accounts: dict[int, TrackedAccount] = {}
96
103
 
@@ -154,16 +161,15 @@ class AuthManager:
154
161
  self._accounts[credentials.account_id] = TrackedAccount(credentials, Authorized())
155
162
 
156
163
  def replace_credentials(self, credentials: AccountCredentials) -> None:
157
- """Swap in rotated tokens. Leave the session state as it was.
164
+ """Swap in rotated tokens for an account the manager holds. Leave the session state as it was.
158
165
 
159
166
  Args:
160
167
  credentials: The rotated credentials.
161
168
  """
162
169
  current = self._accounts.get(credentials.account_id)
163
- self._accounts[credentials.account_id] = TrackedAccount(
164
- credentials,
165
- current.session if current is not None else None,
166
- )
170
+ if current is None:
171
+ return
172
+ self._accounts[credentials.account_id] = TrackedAccount(credentials, current.session)
167
173
 
168
174
  def invalidate_all(self) -> None:
169
175
  """Forget every server-side session. Keep the credentials.
@@ -255,16 +261,19 @@ class AuthManager:
255
261
  ) -> None:
256
262
  """Authenticate a trading account and keep its session alive.
257
263
 
258
- The credentials are kept, so the tokens are refreshed before they
259
- expire and the session is re-established after a disconnect.
264
+ An expired access token is refreshed first. The credentials are kept,
265
+ so the tokens are refreshed before they expire and the session is
266
+ re-established after a disconnect.
260
267
 
261
268
  Args:
262
269
  credentials: The account credentials including tokens.
263
270
  timeout: Request timeout in seconds. None uses the configured default.
264
271
 
265
272
  Raises:
266
- TokenExpiredError: If the access token has expired or the server
267
- rejects it as invalid.
273
+ TokenExpiredError: If the server rejects the access token, or if
274
+ the access token has expired and the server rejects the
275
+ refresh token.
276
+ APIError: If the server refuses a refresh for another reason.
268
277
  AccountAuthError: If the server rejects the account.
269
278
  CTraderConnectionTimeoutError: If request times out.
270
279
  """
@@ -278,6 +287,9 @@ class AuthManager:
278
287
  ) -> None:
279
288
  """Authorize an account on the current link.
280
289
 
290
+ An expired access token is refreshed first, so a process that starts
291
+ or reconnects after the expiry needs no new token from the outside.
292
+
281
293
  Args:
282
294
  credentials: The account credentials including tokens.
283
295
  trigger: Why the authentication happens. A token refresh renews
@@ -287,8 +299,10 @@ class AuthManager:
287
299
  timeout: Request timeout in seconds. None uses the configured default.
288
300
 
289
301
  Raises:
290
- TokenExpiredError: If the access token has expired or the server
291
- rejects it as invalid.
302
+ TokenExpiredError: If the server rejects the access token, or if
303
+ the access token has expired and the server rejects the
304
+ refresh token.
305
+ APIError: If the server refuses a refresh for another reason.
292
306
  AccountAuthError: If the server rejects the account.
293
307
  CTraderConnectionTimeoutError: If request times out.
294
308
  """
@@ -298,7 +312,8 @@ class AuthManager:
298
312
  logger.debug("Re-authenticating account %d (%s)", credentials.account_id, trigger.value)
299
313
 
300
314
  if credentials.is_expired():
301
- raise TokenExpiredError(credentials.account_id)
315
+ logger.debug("Access token for account %d has expired; refreshing first", credentials.account_id)
316
+ credentials = await self.refresh(credentials, timeout=timeout)
302
317
 
303
318
  request = ProtoOAAccountAuthReq(
304
319
  ctid_trader_account_id=credentials.account_id,
@@ -323,6 +338,45 @@ class AuthManager:
323
338
  await self._restorer.restore(credentials.account_id)
324
339
  await self._publisher.emit(ReadyEvent(account_id=credentials.account_id, trigger=trigger))
325
340
 
341
+ async def refresh(self, credentials: AccountCredentials, timeout: float | None = None) -> AccountCredentials:
342
+ """Rotate the tokens of an account and keep the new pair.
343
+
344
+ The server invalidates the old pair as it issues the new one, so the new
345
+ pair replaces the held one before the store saves it.
346
+
347
+ Args:
348
+ credentials: The credentials whose refresh token is presented.
349
+ timeout: Request timeout in seconds. None uses the configured default.
350
+
351
+ Returns:
352
+ The rotated credentials.
353
+
354
+ Raises:
355
+ TokenExpiredError: If the refresh token is dead.
356
+ APIError: If the server refuses the request for another reason.
357
+ CTraderConnectionTimeoutError: If the request times out.
358
+ """
359
+ request = ProtoOARefreshTokenReq(refresh_token=credentials.refresh_token)
360
+ try:
361
+ response = await self._protocol.request(request, ProtoOARefreshTokenRes, timeout=timeout)
362
+ except APIError as e:
363
+ if e.is_token_failure():
364
+ raise TokenExpiredError(credentials.account_id) from e
365
+ raise
366
+
367
+ rotated = credentials.with_refreshed_tokens(
368
+ access_token=response.access_token,
369
+ refresh_token=response.refresh_token,
370
+ expires_in=response.expires_in,
371
+ )
372
+ self.replace_credentials(rotated)
373
+
374
+ if self._token_store is not None:
375
+ await self._token_store.save(rotated)
376
+
377
+ logger.debug("Token refreshed for account %d, new expiry in %ds", credentials.account_id, response.expires_in)
378
+ return rotated
379
+
326
380
  async def probe_session(self, account_id: int, timeout: float | None = None) -> bool:
327
381
  """Return whether the account still holds a live session on this link.
328
382
 
@@ -91,16 +91,15 @@ def build_graph(
91
91
  client_id=config.client_id,
92
92
  client_secret=config.client_secret,
93
93
  restorer=market_data,
94
+ token_store=token_store,
94
95
  )
95
96
 
96
97
  maintainer = SessionMaintainer(
97
- protocol=protocol,
98
98
  manager=auth,
99
99
  publisher=emitter,
100
100
  refresh_policy=config.refresh_policy,
101
101
  reauth_policy=config.reauth_policy,
102
102
  clock=clock,
103
- token_store=token_store,
104
103
  )
105
104
 
106
105
  router = EventRouter(
@@ -151,11 +151,11 @@ class AccountAuthError(AuthenticationError):
151
151
 
152
152
 
153
153
  class TokenExpiredError(AuthenticationError):
154
- """Access token has expired."""
154
+ """The server refused the access token, or the refresh token that was to replace it."""
155
155
 
156
156
  def __init__(self, ctid_trader_account_id: int | None = None) -> None:
157
157
  self.ctid_trader_account_id = ctid_trader_account_id
158
- message = "Access token has expired"
158
+ message = "Token refused by the server"
159
159
  if ctid_trader_account_id:
160
160
  message += f" for account {ctid_trader_account_id}"
161
161
  super().__init__(message)
@@ -13,7 +13,7 @@ from ctrader_api_client.auth import (
13
13
  SessionMaintainer,
14
14
  )
15
15
 
16
- from ...harness import ManualClock, RecordingPublisher, StubProtocol
16
+ from ...harness import ManualClock, RecordingPublisher, RecordingStore, StubProtocol
17
17
 
18
18
 
19
19
  @pytest.fixture
@@ -22,18 +22,26 @@ def publisher() -> RecordingPublisher:
22
22
  return RecordingPublisher()
23
23
 
24
24
 
25
+ @pytest.fixture
26
+ def store() -> RecordingStore:
27
+ """Records every pair the manager under test persists."""
28
+ return RecordingStore()
29
+
30
+
25
31
  @pytest.fixture
26
32
  def make_auth(
27
33
  protocol: StubProtocol,
28
34
  publisher: RecordingPublisher,
35
+ store: RecordingStore,
29
36
  ) -> Callable[..., AuthManager]:
30
- """Build auth managers."""
37
+ """Build auth managers that persist through the recording store."""
31
38
 
32
39
  def factory(**overrides: object) -> AuthManager:
33
40
  settings: dict[str, object] = {
34
41
  "publisher": publisher,
35
42
  "client_id": "test-client-id",
36
43
  "client_secret": "test-client-secret",
44
+ "token_store": store,
37
45
  }
38
46
  settings.update(overrides)
39
47
  return AuthManager(protocol=protocol, **settings) # type: ignore[arg-type]
@@ -49,13 +57,15 @@ def auth(make_auth: Callable[..., AuthManager]) -> AuthManager:
49
57
 
50
58
  @pytest.fixture
51
59
  def make_maintainer(
52
- protocol: StubProtocol,
53
60
  clock: ManualClock,
54
61
  publisher: RecordingPublisher,
55
62
  auth: AuthManager,
56
63
  serving: Callable[[SessionMaintainer], None],
57
64
  ) -> Callable[..., SessionMaintainer]:
58
- """Build maintainers over the auth manager, with their loops already running.
65
+ """Build maintainers over an auth manager, with their loops already running.
66
+
67
+ The default manager is the `auth` fixture. A test that builds its own
68
+ manager, for example over a store that fails, passes it as `manager`.
59
69
 
60
70
  Every wait runs on the injected clock. The refresh retry wait is zero
61
71
  unless a test needs the retry to park, and the check interval stays
@@ -66,11 +76,10 @@ def make_maintainer(
66
76
  check_interval: float = 60.0,
67
77
  retry_wait: float = 0.0,
68
78
  reauth_policy: ReauthPolicy | None = None,
69
- **overrides: object,
79
+ manager: AuthManager | None = None,
70
80
  ) -> SessionMaintainer:
71
81
  maintainer = SessionMaintainer(
72
- protocol=protocol,
73
- manager=auth,
82
+ manager=manager if manager is not None else auth,
74
83
  publisher=publisher,
75
84
  refresh_policy=RefreshPolicy(
76
85
  check_interval=check_interval,
@@ -79,7 +88,6 @@ def make_maintainer(
79
88
  ),
80
89
  reauth_policy=reauth_policy if reauth_policy is not None else ReauthPolicy(min_wait=0.001, max_wait=0.01),
81
90
  clock=clock,
82
- **overrides, # type: ignore[arg-type]
83
91
  )
84
92
  serving(maintainer)
85
93
  return maintainer