ctrader-api-client 0.7.2__tar.gz → 0.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/PKG-INFO +7 -3
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/README.md +6 -2
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/api/events.md +8 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/api/exceptions.md +26 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/getting-started.md +11 -2
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/pyproject.toml +2 -1
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/__init__.py +2 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/_recovery.py +89 -3
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/composition.py +1 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/config.py +8 -2
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/connection/protocol.py +129 -38
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/connection/transport.py +31 -13
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/events/types.py +7 -1
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/exceptions.py +33 -0
- ctrader_api_client-0.8.0/tests/behavior/connection/test_reconnection.py +476 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/test_client.py +78 -1
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/uv.lock +1 -1
- ctrader_api_client-0.7.2/tests/behavior/connection/test_reconnection.py +0 -161
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/.claude/settings.local.json +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/.github/workflows/docs.yml +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/.gitignore +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/.pre-commit-config.yaml +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/.python-version +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/Justfile +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/LICENSE +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/api/accounts.md +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/api/client.md +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/api/enums.md +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/api/market-data.md +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/api/models.md +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/api/symbols.md +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/api/trading.md +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/docs/index.md +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/mkdocs.yml +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/protos/SOURCE +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/protos/VERSION +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/protos/update.sh +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/protos/vendor/OpenApiCommonMessages.proto +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/protos/vendor/OpenApiCommonModelMessages.proto +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/protos/vendor/OpenApiMessages.proto +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/protos/vendor/OpenApiModelMessages.proto +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/scripts/fix_proto_imports.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/__init__.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/clock.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/conversions.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/messages.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonMessages.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/proto/OpenApiCommonModelMessages.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/proto/OpenApiMessages.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/proto/OpenApiModelMessages.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/proto/__init__.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/_internal/serialization.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/api/__init__.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/api/_base.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/api/accounts.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/api/market_data.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/api/symbols.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/api/trading.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/__init__.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/_refresh.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/_session.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/credentials.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/manager.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/policy.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/store.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/client.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/connection/__init__.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/connection/heartbeat.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/connection/listener.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/connection/supervisor.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/enums.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/events/__init__.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/events/_execution.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/events/emitter.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/events/publisher.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/events/router.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/__init__.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/_base.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/account.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/deal.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/market_data.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/order.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/position.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/requests.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/models/symbol.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/py.typed +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/__init__.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/__init__.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/api/__init__.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/api/conftest.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/api/test_market_data.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/api/test_reference_data.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/api/test_subscription_restore.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/api/test_trading.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/auth/__init__.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/auth/conftest.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/auth/test_authentication.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/auth/test_token_lifecycle.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/connection/__init__.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/connection/conftest.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/connection/test_event_dispatch.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/connection/test_framing.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/connection/test_heartbeat.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/connection/test_request_response.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/events/__init__.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/events/conftest.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/events/test_routing.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/behavior/events/test_subscriptions.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/conftest.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/harness/__init__.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/harness/clock.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/harness/factories.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/harness/recorder.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/harness/server.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/harness/signals.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/harness/stub_protocol.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/harness/wire.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/unit/__init__.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/unit/test_api_errors.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/unit/test_market_data_conversion.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/unit/test_request_conversion.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/unit/test_symbol_conversion.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/unit/test_trading_records.py +0 -0
- {ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/tests/unit/test_wire_format.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: ctrader-api-client
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.8.0
|
|
4
4
|
Summary: API Client to interact with the cTrader Open API spec
|
|
5
5
|
Author-email: Elio <elioachukri@pm.me>
|
|
6
6
|
License-File: LICENSE
|
|
@@ -354,10 +354,14 @@ config = ClientConfig(
|
|
|
354
354
|
heartbeat_timeout=30.0, # Or 0 to disable server heartbeat checks
|
|
355
355
|
request_timeout=30.0,
|
|
356
356
|
|
|
357
|
-
# Reconnection
|
|
358
|
-
|
|
357
|
+
# Reconnection: None retries for as long as the client is open (the
|
|
358
|
+
# default), an integer caps the attempts, 0 disables reconnection. A
|
|
359
|
+
# finite budget that runs out raises CTraderReconnectAbandonedError out
|
|
360
|
+
# of the `async with client:` block.
|
|
361
|
+
reconnect_attempts=None,
|
|
359
362
|
reconnect_min_wait=1.0,
|
|
360
363
|
reconnect_max_wait=60.0,
|
|
364
|
+
connect_timeout=30.0,
|
|
361
365
|
|
|
362
366
|
# Token refresh: when to refresh access tokens and how hard to retry
|
|
363
367
|
refresh_policy=RefreshPolicy(
|
|
@@ -335,10 +335,14 @@ config = ClientConfig(
|
|
|
335
335
|
heartbeat_timeout=30.0, # Or 0 to disable server heartbeat checks
|
|
336
336
|
request_timeout=30.0,
|
|
337
337
|
|
|
338
|
-
# Reconnection
|
|
339
|
-
|
|
338
|
+
# Reconnection: None retries for as long as the client is open (the
|
|
339
|
+
# default), an integer caps the attempts, 0 disables reconnection. A
|
|
340
|
+
# finite budget that runs out raises CTraderReconnectAbandonedError out
|
|
341
|
+
# of the `async with client:` block.
|
|
342
|
+
reconnect_attempts=None,
|
|
340
343
|
reconnect_min_wait=1.0,
|
|
341
344
|
reconnect_max_wait=60.0,
|
|
345
|
+
connect_timeout=30.0,
|
|
342
346
|
|
|
343
347
|
# Token refresh: when to refresh access tokens and how hard to retry
|
|
344
348
|
refresh_policy=RefreshPolicy(
|
|
@@ -146,6 +146,14 @@ async def on_reconnected(event: ReconnectedEvent):
|
|
|
146
146
|
print(f"Failed accounts: {event.failed_accounts}")
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
+
An `app_auth_restored=False` is not the end of the story. The server can refuse
|
|
150
|
+
application authentication for transient reasons of its own, so the client keeps
|
|
151
|
+
retrying with backoff and announces each account it restores afterwards with a
|
|
152
|
+
`ReadyEvent`. Treat this event as a signal that the link is back but degraded —
|
|
153
|
+
stop acting on the session until the accounts are announced ready — rather than
|
|
154
|
+
as a permanent failure. Check the current state with
|
|
155
|
+
`client.auth.is_app_authenticated`.
|
|
156
|
+
|
|
149
157
|
::: ctrader_api_client.events.ClientDisconnectEvent
|
|
150
158
|
options:
|
|
151
159
|
show_source: false
|
|
@@ -153,6 +153,32 @@ other handlers for that event.
|
|
|
153
153
|
options:
|
|
154
154
|
show_source: false
|
|
155
155
|
|
|
156
|
+
::: ctrader_api_client.CTraderReconnectAbandonedError
|
|
157
|
+
options:
|
|
158
|
+
show_source: false
|
|
159
|
+
|
|
160
|
+
This is the one connection exception that reaches you without your having called
|
|
161
|
+
anything: it is raised out of the `async with client:` block itself. Reconnection
|
|
162
|
+
is unbounded by default, so seeing it means either that `reconnect_attempts` was
|
|
163
|
+
set to a finite number and every one was spent, or that something failed which
|
|
164
|
+
the reconnection logic did not anticipate.
|
|
165
|
+
|
|
166
|
+
```python
|
|
167
|
+
while True:
|
|
168
|
+
try:
|
|
169
|
+
async with client:
|
|
170
|
+
await asyncio.Event().wait()
|
|
171
|
+
except CTraderReconnectAbandonedError:
|
|
172
|
+
logger.exception("Client abandoned its connection; starting a new one")
|
|
173
|
+
await asyncio.sleep(30)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
Catching it is only worthwhile if you intend to build a fresh client, since the
|
|
177
|
+
one that raised it is finished. Letting it propagate and having a process
|
|
178
|
+
supervisor restart you is an equally good answer, and the reason it is raised
|
|
179
|
+
rather than logged: a client that has stopped reconnecting has no reader and no
|
|
180
|
+
heartbeat left to notice anything, so nothing short of a restart brings it back.
|
|
181
|
+
|
|
156
182
|
### Authentication
|
|
157
183
|
|
|
158
184
|
::: ctrader_api_client.AuthenticationError
|
|
@@ -312,13 +312,22 @@ config = ClientConfig(
|
|
|
312
312
|
heartbeat_timeout=0, # 0 to disable server heartbeat checks (default)
|
|
313
313
|
request_timeout=30.0,
|
|
314
314
|
|
|
315
|
-
# Reconnection
|
|
316
|
-
|
|
315
|
+
# Reconnection: None retries for as long as the client is open (the
|
|
316
|
+
# default), an integer caps the attempts, 0 disables reconnection.
|
|
317
|
+
reconnect_attempts=None,
|
|
317
318
|
reconnect_min_wait=1.0,
|
|
318
319
|
reconnect_max_wait=60.0,
|
|
320
|
+
connect_timeout=30.0,
|
|
319
321
|
)
|
|
320
322
|
```
|
|
321
323
|
|
|
324
|
+
Reconnection is unbounded by default because a finite budget that runs out
|
|
325
|
+
leaves a client that can never recover: there is no reader and no heartbeat left
|
|
326
|
+
to notice anything, so it stays offline until the process restarts. If you do
|
|
327
|
+
set a finite `reconnect_attempts`, spending it raises
|
|
328
|
+
`CTraderReconnectAbandonedError` out of the `async with client:` block rather
|
|
329
|
+
than leaving the client alive and permanently disconnected.
|
|
330
|
+
|
|
322
331
|
## Next Steps
|
|
323
332
|
|
|
324
333
|
- [API Reference - Client](api/client.md) - Full client documentation
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "ctrader-api-client"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.8.0"
|
|
4
4
|
description = "API Client to interact with the cTrader Open API spec"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -79,6 +79,7 @@ select = [
|
|
|
79
79
|
"RUF022", # Unsorted __all__ blocks
|
|
80
80
|
"FURB157", # Check unnecessary casts in Decimal constructors
|
|
81
81
|
"RUF032", # Check for Decimal calls passing a float literal
|
|
82
|
+
"G004", # Check for all f strings in logging calls
|
|
82
83
|
]
|
|
83
84
|
|
|
84
85
|
[tool.ruff.lint.per-file-ignores]
|
|
@@ -92,6 +92,7 @@ from .exceptions import (
|
|
|
92
92
|
CTraderConnectionFailedError,
|
|
93
93
|
CTraderConnectionTimeoutError,
|
|
94
94
|
CTraderError,
|
|
95
|
+
CTraderReconnectAbandonedError,
|
|
95
96
|
DeserializationError,
|
|
96
97
|
FramingError,
|
|
97
98
|
ProtocolError,
|
|
@@ -139,6 +140,7 @@ __all__ = [
|
|
|
139
140
|
"CTraderConnectionFailedError",
|
|
140
141
|
"CTraderConnectionTimeoutError",
|
|
141
142
|
"CTraderError",
|
|
143
|
+
"CTraderReconnectAbandonedError",
|
|
142
144
|
"ClientConfig",
|
|
143
145
|
"ClientDisconnectEvent",
|
|
144
146
|
"CloseDetail",
|
{ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/auth/_recovery.py
RENAMED
|
@@ -60,6 +60,14 @@ class SessionRecovery:
|
|
|
60
60
|
# repeated report does not start a second check against the server.
|
|
61
61
|
self._verifying: set[int] = set()
|
|
62
62
|
|
|
63
|
+
# A reconnect whose application re-authentication failed, left for the
|
|
64
|
+
# recovery loop to keep retrying. Nothing can be authorized on a link
|
|
65
|
+
# the application itself is not authenticated on, so this is settled
|
|
66
|
+
# before any account is attempted.
|
|
67
|
+
self._app_auth_pending = False
|
|
68
|
+
self._app_auth_attempts = 0
|
|
69
|
+
self._app_auth_next_at = 0.0
|
|
70
|
+
|
|
63
71
|
self._task_scope: anyio.CancelScope | None = None
|
|
64
72
|
self._running = False
|
|
65
73
|
|
|
@@ -85,6 +93,7 @@ class SessionRecovery:
|
|
|
85
93
|
finally:
|
|
86
94
|
self._running = False
|
|
87
95
|
self._task_scope = None
|
|
96
|
+
self._app_auth_pending = False
|
|
88
97
|
self._store.invalidate_all()
|
|
89
98
|
logger.debug("Session recovery monitor stopped")
|
|
90
99
|
|
|
@@ -181,6 +190,11 @@ class SessionRecovery:
|
|
|
181
190
|
credentials are kept, so the sessions can be re-established once the
|
|
182
191
|
link is back.
|
|
183
192
|
"""
|
|
193
|
+
# Any application re-authentication still being retried belonged to the
|
|
194
|
+
# link that just died. The reconnection attempts it again from scratch
|
|
195
|
+
# and re-arms this if it has to, so carrying the old state forward would
|
|
196
|
+
# only make the next backoff start deeper than it should.
|
|
197
|
+
self._app_auth_pending = False
|
|
184
198
|
self._store.invalidate_all()
|
|
185
199
|
|
|
186
200
|
async def on_connection_restored(self) -> None:
|
|
@@ -190,6 +204,11 @@ class SessionRecovery:
|
|
|
190
204
|
link is already back, and a caller can only act on a failure it is told
|
|
191
205
|
about. Without application authentication nothing else can be
|
|
192
206
|
attempted, so that failure is reported on its own.
|
|
207
|
+
|
|
208
|
+
Only the first attempt is made here. This runs inside the protocol's
|
|
209
|
+
reconnection task, which cannot detect a further drop while it is
|
|
210
|
+
blocked, so a failure is handed to the recovery loop to keep retrying in
|
|
211
|
+
the background rather than waited out inline.
|
|
193
212
|
"""
|
|
194
213
|
logger.debug("Connection restored, re-authenticating...")
|
|
195
214
|
|
|
@@ -197,7 +216,8 @@ class SessionRecovery:
|
|
|
197
216
|
await self._authenticator.authenticate_app()
|
|
198
217
|
logger.debug("App re-authenticated successfully")
|
|
199
218
|
except Exception as e:
|
|
200
|
-
logger.error("Failed to re-authenticate app after reconnect: %s", e)
|
|
219
|
+
logger.error("Failed to re-authenticate app after reconnect, will retry: %s", e)
|
|
220
|
+
self._arm_app_auth_recovery()
|
|
201
221
|
await self._publisher.emit(
|
|
202
222
|
ReconnectedEvent(
|
|
203
223
|
app_auth_restored=False,
|
|
@@ -228,15 +248,66 @@ class SessionRecovery:
|
|
|
228
248
|
)
|
|
229
249
|
)
|
|
230
250
|
|
|
251
|
+
def _backoff_delay(self, attempts: int) -> float:
|
|
252
|
+
"""How long to wait before attempt number `attempts`, per the policy."""
|
|
253
|
+
return min(self._policy.min_wait * 2 ** (attempts - 1), self._policy.max_wait)
|
|
254
|
+
|
|
231
255
|
def _defer_recovery(self, account_id: int, state: AwaitingRecovery) -> None:
|
|
232
256
|
"""Back off before the next recovery attempt for a single account."""
|
|
233
257
|
attempts = state.attempts + 1
|
|
234
|
-
delay = min(self._policy.min_wait * 2 ** (attempts - 1), self._policy.max_wait)
|
|
235
258
|
self._store.reschedule_recovery(
|
|
236
259
|
account_id,
|
|
237
|
-
AwaitingRecovery(
|
|
260
|
+
AwaitingRecovery(
|
|
261
|
+
attempts=attempts,
|
|
262
|
+
next_attempt_at=self._clock.now() + self._backoff_delay(attempts),
|
|
263
|
+
),
|
|
238
264
|
)
|
|
239
265
|
|
|
266
|
+
def _arm_app_auth_recovery(self) -> None:
|
|
267
|
+
"""Hand a failed application re-authentication to the recovery loop.
|
|
268
|
+
|
|
269
|
+
Every account is flagged alongside it: their sessions died with the
|
|
270
|
+
link, and the re-authentication that would have restored them is the
|
|
271
|
+
very thing that failed, so they are recovered by the same loop once the
|
|
272
|
+
application is authenticated again.
|
|
273
|
+
"""
|
|
274
|
+
self._app_auth_pending = True
|
|
275
|
+
self._app_auth_attempts = 0
|
|
276
|
+
self._defer_app_auth()
|
|
277
|
+
|
|
278
|
+
for credentials in self._store.all_credentials():
|
|
279
|
+
self._store.flag_for_recovery(credentials.account_id)
|
|
280
|
+
|
|
281
|
+
self._reauth_signal.set()
|
|
282
|
+
|
|
283
|
+
def _defer_app_auth(self) -> None:
|
|
284
|
+
"""Back off before the next application re-authentication attempt."""
|
|
285
|
+
self._app_auth_attempts += 1
|
|
286
|
+
self._app_auth_next_at = self._clock.now() + self._backoff_delay(self._app_auth_attempts)
|
|
287
|
+
|
|
288
|
+
async def _retry_app_auth(self) -> bool:
|
|
289
|
+
"""Attempt the deferred application re-authentication once.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
True if the application is authenticated again, False if the
|
|
293
|
+
attempt failed and has been deferred for another try.
|
|
294
|
+
"""
|
|
295
|
+
try:
|
|
296
|
+
await self._authenticator.authenticate_app()
|
|
297
|
+
except Exception as e:
|
|
298
|
+
self._defer_app_auth()
|
|
299
|
+
logger.warning(
|
|
300
|
+
"Re-authenticating the application failed (attempt %d), will retry: %s",
|
|
301
|
+
self._app_auth_attempts,
|
|
302
|
+
e,
|
|
303
|
+
)
|
|
304
|
+
return False
|
|
305
|
+
|
|
306
|
+
self._app_auth_pending = False
|
|
307
|
+
self._app_auth_attempts = 0
|
|
308
|
+
logger.info("Application re-authenticated; restoring accounts")
|
|
309
|
+
return True
|
|
310
|
+
|
|
240
311
|
async def _wait_for_retry(self, delay: float, flagged: anyio.Event) -> None:
|
|
241
312
|
"""Wait out a backoff, returning early if another account needs recovery.
|
|
242
313
|
|
|
@@ -266,6 +337,11 @@ class SessionRecovery:
|
|
|
266
337
|
and retries continue until every account succeeds, is removed, or the
|
|
267
338
|
monitor stops. A successful re-auth restores authorized state and emits
|
|
268
339
|
a ReadyEvent so subscriptions can be restored.
|
|
340
|
+
|
|
341
|
+
Also carries the application re-authentication a reconnect could not
|
|
342
|
+
complete. That one is not per-account and blocks every account behind
|
|
343
|
+
it, so it is settled first and the accounts are left flagged until it
|
|
344
|
+
succeeds.
|
|
269
345
|
"""
|
|
270
346
|
while self._running:
|
|
271
347
|
# Consume any pending notification before reading the queue, so a
|
|
@@ -274,6 +350,16 @@ class SessionRecovery:
|
|
|
274
350
|
self._reauth_signal = anyio.Event()
|
|
275
351
|
flagged = self._reauth_signal
|
|
276
352
|
|
|
353
|
+
if self._app_auth_pending:
|
|
354
|
+
waiting = self._app_auth_next_at - self._clock.now()
|
|
355
|
+
if waiting > 0:
|
|
356
|
+
await self._wait_for_retry(waiting, flagged)
|
|
357
|
+
continue
|
|
358
|
+
if not await self._retry_app_auth():
|
|
359
|
+
continue
|
|
360
|
+
# Authenticating the application is exactly what the flagged
|
|
361
|
+
# accounts were held up by, so go straight on to them.
|
|
362
|
+
|
|
277
363
|
queue = self._store.awaiting_recovery()
|
|
278
364
|
if not queue:
|
|
279
365
|
await flagged.wait()
|
|
@@ -17,7 +17,12 @@ class ClientConfig(BaseModel):
|
|
|
17
17
|
heartbeat_interval: Seconds between heartbeat sends.
|
|
18
18
|
heartbeat_timeout: Seconds without server-initiated messages before disconnect. Set to 0 to disable.
|
|
19
19
|
request_timeout: Default timeout for API requests in seconds.
|
|
20
|
-
|
|
20
|
+
connect_timeout: Seconds allowed for the TCP connection and TLS
|
|
21
|
+
handshake together, after which the attempt is failed and retried.
|
|
22
|
+
reconnect_attempts: Max reconnection attempts. None (the default)
|
|
23
|
+
retries for as long as the client is open. 0 disables reconnection.
|
|
24
|
+
A finite budget that runs out abandons the connection and raises
|
|
25
|
+
CTraderReconnectAbandonedError out of the `async with` block.
|
|
21
26
|
reconnect_min_wait: Initial wait between reconnection attempts.
|
|
22
27
|
reconnect_max_wait: Maximum wait between reconnection attempts.
|
|
23
28
|
refresh_policy: When to refresh access tokens and how hard to try.
|
|
@@ -51,9 +56,10 @@ class ClientConfig(BaseModel):
|
|
|
51
56
|
|
|
52
57
|
# Request settings
|
|
53
58
|
request_timeout: float = Field(default=30.0, gt=0)
|
|
59
|
+
connect_timeout: float = Field(default=30.0, gt=0)
|
|
54
60
|
|
|
55
61
|
# Reconnection settings
|
|
56
|
-
reconnect_attempts: int = Field(default=
|
|
62
|
+
reconnect_attempts: int | None = Field(default=None, ge=0)
|
|
57
63
|
reconnect_min_wait: float = Field(default=1.0, gt=0)
|
|
58
64
|
reconnect_max_wait: float = Field(default=60.0, gt=0)
|
|
59
65
|
|
{ctrader_api_client-0.7.2 → ctrader_api_client-0.8.0}/src/ctrader_api_client/connection/protocol.py
RENAMED
|
@@ -12,6 +12,7 @@ from tenacity import (
|
|
|
12
12
|
AsyncRetrying,
|
|
13
13
|
retry_if_exception_type,
|
|
14
14
|
stop_after_attempt,
|
|
15
|
+
stop_never,
|
|
15
16
|
wait_exponential,
|
|
16
17
|
)
|
|
17
18
|
|
|
@@ -29,6 +30,7 @@ from ..exceptions import (
|
|
|
29
30
|
CTraderConnectionClosedError,
|
|
30
31
|
CTraderConnectionFailedError,
|
|
31
32
|
CTraderConnectionTimeoutError,
|
|
33
|
+
CTraderReconnectAbandonedError,
|
|
32
34
|
FramingError,
|
|
33
35
|
)
|
|
34
36
|
from .listener import ConnectionListener
|
|
@@ -73,7 +75,7 @@ class Protocol:
|
|
|
73
75
|
def __init__(
|
|
74
76
|
self,
|
|
75
77
|
transport: Transport,
|
|
76
|
-
reconnect_attempts: int =
|
|
78
|
+
reconnect_attempts: int | None = None,
|
|
77
79
|
reconnect_min_wait: float = 1.0,
|
|
78
80
|
reconnect_max_wait: float = 60.0,
|
|
79
81
|
) -> None:
|
|
@@ -81,7 +83,10 @@ class Protocol:
|
|
|
81
83
|
|
|
82
84
|
Args:
|
|
83
85
|
transport: The underlying transport for sending/receiving data.
|
|
84
|
-
reconnect_attempts: Maximum reconnection attempts
|
|
86
|
+
reconnect_attempts: Maximum reconnection attempts. None retries for
|
|
87
|
+
as long as the client is open, which is the default: an outage
|
|
88
|
+
that outlasts a finite budget otherwise leaves a client that
|
|
89
|
+
can never recover. 0 disables reconnection entirely.
|
|
85
90
|
reconnect_min_wait: Initial wait between attempts (seconds).
|
|
86
91
|
reconnect_max_wait: Maximum wait between attempts (seconds).
|
|
87
92
|
"""
|
|
@@ -109,6 +114,11 @@ class Protocol:
|
|
|
109
114
|
# reader loop and the heartbeat loop both detecting the same drop).
|
|
110
115
|
self._reconnecting = False
|
|
111
116
|
|
|
117
|
+
# Set when the link drops again while a reconnection is already in
|
|
118
|
+
# flight. The detector that saw it cannot start its own reconnection,
|
|
119
|
+
# so the one already running goes round again on its behalf.
|
|
120
|
+
self._redo_requested = False
|
|
121
|
+
|
|
112
122
|
# Told about link transitions; set by whoever owns this protocol.
|
|
113
123
|
self._listener: ConnectionListener | None = None
|
|
114
124
|
|
|
@@ -444,11 +454,17 @@ class Protocol:
|
|
|
444
454
|
standalone task decouples the reconnection from the cancel scope of
|
|
445
455
|
whichever loop detected the drop.
|
|
446
456
|
|
|
447
|
-
Idempotent: calls made while a reconnection is already in flight
|
|
448
|
-
|
|
449
|
-
drop only produce a single reconnection.
|
|
457
|
+
Idempotent: calls made while a reconnection is already in flight do not
|
|
458
|
+
start a second one, so the reader and heartbeat loops racing to report
|
|
459
|
+
the same drop only produce a single reconnection. Such a call is still
|
|
460
|
+
recorded, because it may be reporting a *fresh* drop of the link the
|
|
461
|
+
running reconnection just restored — and the caller cannot retry for
|
|
462
|
+
itself, since the reader loop exits as soon as it has reported.
|
|
450
463
|
"""
|
|
451
|
-
if not self._running
|
|
464
|
+
if not self._running:
|
|
465
|
+
return
|
|
466
|
+
if self._reconnecting:
|
|
467
|
+
self._redo_requested = True
|
|
452
468
|
return
|
|
453
469
|
if self._task_group is None:
|
|
454
470
|
return
|
|
@@ -457,16 +473,25 @@ class Protocol:
|
|
|
457
473
|
logger.warning("Connection lost, attempting to reconnect...")
|
|
458
474
|
|
|
459
475
|
# State tied to the dead link is invalid from this moment on. Report it
|
|
460
|
-
# before reconnecting so nothing observes a session that no longer
|
|
461
|
-
|
|
462
|
-
if self._listener is not None:
|
|
463
|
-
try:
|
|
464
|
-
await self._listener.on_connection_lost()
|
|
465
|
-
except Exception:
|
|
466
|
-
logger.exception("Connection listener failed on disconnect")
|
|
476
|
+
# before reconnecting so nothing observes a session that no longer exists.
|
|
477
|
+
await self._notify_connection_lost()
|
|
467
478
|
|
|
468
479
|
self._task_group.start_soon(self._reconnect_task)
|
|
469
480
|
|
|
481
|
+
async def _notify_connection_lost(self) -> None:
|
|
482
|
+
"""Tell the listener the link is gone.
|
|
483
|
+
|
|
484
|
+
Never lets a failing listener block the reconnection: whatever
|
|
485
|
+
bookkeeping it does matters less than getting the link back.
|
|
486
|
+
"""
|
|
487
|
+
if self._listener is None:
|
|
488
|
+
return
|
|
489
|
+
|
|
490
|
+
try:
|
|
491
|
+
await self._listener.on_connection_lost()
|
|
492
|
+
except Exception:
|
|
493
|
+
logger.exception("Connection listener failed on disconnect")
|
|
494
|
+
|
|
470
495
|
async def _reconnect_task(self) -> None:
|
|
471
496
|
"""Own the full reconnection lifecycle from a standalone task.
|
|
472
497
|
|
|
@@ -475,32 +500,90 @@ class Protocol:
|
|
|
475
500
|
escape: an unhandled error here would propagate into the protocol task
|
|
476
501
|
group and tear down every other task. A terminal reconnection failure is
|
|
477
502
|
instead recorded by stopping the protocol and waking pending requests.
|
|
503
|
+
|
|
504
|
+
Loops rather than running once, so a link that drops again while this is
|
|
505
|
+
working — reported through `handle_disconnect` as `_redo_requested`, the
|
|
506
|
+
only route left once the reader has exited — is reconnected too instead
|
|
507
|
+
of being stranded.
|
|
508
|
+
|
|
509
|
+
Raises:
|
|
510
|
+
CTraderReconnectAbandonedError: If reconnection is given up on. This
|
|
511
|
+
is deliberately allowed to escape into the protocol task group
|
|
512
|
+
and tear the client down: see :meth:`_abandon`.
|
|
478
513
|
"""
|
|
479
514
|
try:
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
515
|
+
while True:
|
|
516
|
+
self._redo_requested = False
|
|
517
|
+
|
|
518
|
+
# The reader loop is reading a dead stream; cancel it before we
|
|
519
|
+
# reconnect so a stale reader can't race the fresh one we restart.
|
|
520
|
+
if self._reader_scope is not None:
|
|
521
|
+
self._reader_scope.cancel()
|
|
522
|
+
self._reader_scope = None
|
|
523
|
+
|
|
524
|
+
# Close the old transport (idempotent — the task that detected
|
|
525
|
+
# the drop may have already raced us to it). Tearing down a dead
|
|
526
|
+
# socket can fail on its own, which says nothing about whether
|
|
527
|
+
# reconnecting will work: swallow it here so it can never be
|
|
528
|
+
# mistaken below for an exhausted reconnection.
|
|
529
|
+
try:
|
|
530
|
+
await self._transport.close()
|
|
531
|
+
except Exception:
|
|
532
|
+
logger.debug("Ignoring error while closing dead transport", exc_info=True)
|
|
533
|
+
|
|
534
|
+
await self._reconnect()
|
|
535
|
+
logger.info("Reconnection successful")
|
|
536
|
+
|
|
537
|
+
if not self._redo_requested:
|
|
538
|
+
break
|
|
539
|
+
|
|
540
|
+
logger.warning("Connection lost again while reconnecting, retrying...")
|
|
541
|
+
# The sessions this pass just restored died with the link that
|
|
542
|
+
# dropped underneath it, so report that loss before going round
|
|
543
|
+
# again — nothing else will, since `handle_disconnect` only
|
|
544
|
+
# recorded the drop rather than handling it.
|
|
545
|
+
await self._notify_connection_lost()
|
|
492
546
|
except (CTraderConnectionFailedError, CTraderConnectionClosedError) as e:
|
|
493
547
|
logger.error("Reconnection failed, giving up: %s", e)
|
|
494
|
-
self.
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
#
|
|
548
|
+
self._abandon("connection lost and reconnection failed", e)
|
|
549
|
+
except Exception as e:
|
|
550
|
+
# Nothing here is expected to fail this way — the retry loop below
|
|
551
|
+
# absorbs every failure connecting — so treat it as a defect rather
|
|
552
|
+
# than a state to sit quietly in.
|
|
499
553
|
logger.exception("Unexpected error while reconnecting")
|
|
500
|
-
self.
|
|
501
|
-
self._fail_pending("Reconnection error")
|
|
554
|
+
self._abandon("unexpected error while reconnecting", e)
|
|
502
555
|
finally:
|
|
503
556
|
self._reconnecting = False
|
|
557
|
+
self._redo_requested = False
|
|
558
|
+
|
|
559
|
+
def _abandon(self, reason: str, cause: BaseException) -> None:
|
|
560
|
+
"""Stop the protocol and make giving up impossible to miss.
|
|
561
|
+
|
|
562
|
+
A client that has stopped reconnecting cannot recover on its own: the
|
|
563
|
+
reader has exited, the heartbeat loop returns as soon as its next write
|
|
564
|
+
fails, and `handle_disconnect` refuses everything once `_running` is
|
|
565
|
+
False. Nothing is left that could try again. Staying alive in that state
|
|
566
|
+
only means a consumer polling `is_connected` sees a link that is down
|
|
567
|
+
and assumes, reasonably and wrongly, that something is working on it.
|
|
568
|
+
|
|
569
|
+
So the failure is raised instead of recorded. It escapes into the
|
|
570
|
+
protocol task group, through the supervisor's, and out of the
|
|
571
|
+
`async with client:` block, where a process supervisor will see it.
|
|
572
|
+
|
|
573
|
+
Raises:
|
|
574
|
+
CTraderReconnectAbandonedError: Always, unless reconnection was
|
|
575
|
+
disabled by configuration, which is a choice rather than a
|
|
576
|
+
failure and keeps the previous behaviour of a client that
|
|
577
|
+
rejects further requests.
|
|
578
|
+
"""
|
|
579
|
+
self._running = False
|
|
580
|
+
# Wake all pending requests so callers fail fast instead of hanging.
|
|
581
|
+
self._fail_pending(reason)
|
|
582
|
+
|
|
583
|
+
if self._reconnect_attempts == 0:
|
|
584
|
+
return
|
|
585
|
+
|
|
586
|
+
raise CTraderReconnectAbandonedError(reason, cause) from cause
|
|
504
587
|
|
|
505
588
|
def _fail_pending(self, reason: str) -> None:
|
|
506
589
|
"""Resolve every in-flight request with a connection error."""
|
|
@@ -511,27 +594,35 @@ class Protocol:
|
|
|
511
594
|
async def _reconnect(self) -> None:
|
|
512
595
|
"""Attempt reconnection with exponential backoff.
|
|
513
596
|
|
|
597
|
+
Retries on *any* failure rather than on an enumerated set of exception
|
|
598
|
+
types. The budget exists to outlast a server that cannot be reached,
|
|
599
|
+
and an unrecognised exception is no evidence that it can be: a
|
|
600
|
+
whitelist here meant an exception nobody had thought of skipped the
|
|
601
|
+
retries entirely and went straight to being fatal.
|
|
602
|
+
|
|
514
603
|
Raises:
|
|
515
|
-
CTraderConnectionClosedError: If reconnection is disabled
|
|
516
|
-
CTraderConnectionFailedError: If
|
|
604
|
+
CTraderConnectionClosedError: If reconnection is disabled.
|
|
605
|
+
CTraderConnectionFailedError: If every attempt in the budget failed.
|
|
517
606
|
"""
|
|
518
607
|
if self._reconnect_attempts == 0:
|
|
519
608
|
raise CTraderConnectionClosedError("Connection lost and reconnection disabled")
|
|
520
609
|
|
|
610
|
+
budget = "unlimited" if self._reconnect_attempts is None else self._reconnect_attempts
|
|
611
|
+
|
|
521
612
|
async for attempt in AsyncRetrying(
|
|
522
|
-
stop=stop_after_attempt(self._reconnect_attempts),
|
|
613
|
+
stop=stop_never if self._reconnect_attempts is None else stop_after_attempt(self._reconnect_attempts),
|
|
523
614
|
wait=wait_exponential(
|
|
524
615
|
min=self._reconnect_min_wait,
|
|
525
616
|
max=self._reconnect_max_wait,
|
|
526
617
|
),
|
|
527
|
-
retry=retry_if_exception_type(
|
|
618
|
+
retry=retry_if_exception_type(Exception),
|
|
528
619
|
reraise=True,
|
|
529
620
|
):
|
|
530
621
|
with attempt:
|
|
531
622
|
logger.debug(
|
|
532
|
-
"Reconnection attempt %d/%
|
|
623
|
+
"Reconnection attempt %d/%s",
|
|
533
624
|
attempt.retry_state.attempt_number,
|
|
534
|
-
|
|
625
|
+
budget,
|
|
535
626
|
)
|
|
536
627
|
await self._transport.connect()
|
|
537
628
|
|