kstlib 0.0.1a0__py3-none-any.whl → 1.0.0__py3-none-any.whl
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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.0.dist-info/METADATA +201 -0
- kstlib-1.0.0.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
- kstlib-1.0.0.dist-info/entry_points.txt +2 -0
- kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1102 @@
|
|
|
1
|
+
"""WebSocket manager with proactive connection control.
|
|
2
|
+
|
|
3
|
+
This module provides WebSocketManager, an async WebSocket client that offers
|
|
4
|
+
proactive control over connections rather than just reactive reconnection.
|
|
5
|
+
|
|
6
|
+
The key differentiator is the ability to control WHEN to disconnect/reconnect
|
|
7
|
+
rather than just reacting to disconnections. This is essential for trading
|
|
8
|
+
applications where you want to avoid disconnections during critical operations.
|
|
9
|
+
|
|
10
|
+
Examples:
|
|
11
|
+
Basic usage:
|
|
12
|
+
|
|
13
|
+
>>> from kstlib.websocket import WebSocketManager
|
|
14
|
+
>>> async def main(): # doctest: +SKIP
|
|
15
|
+
... async with WebSocketManager("wss://example.com/ws") as ws:
|
|
16
|
+
... async for message in ws.stream():
|
|
17
|
+
... print(message)
|
|
18
|
+
|
|
19
|
+
Proactive control for trading:
|
|
20
|
+
|
|
21
|
+
>>> def next_candle_in() -> float: # doctest: +SKIP
|
|
22
|
+
... '''Seconds until next 4H candle.'''
|
|
23
|
+
... ...
|
|
24
|
+
>>> async def trading(): # doctest: +SKIP
|
|
25
|
+
... ws = WebSocketManager(
|
|
26
|
+
... url="wss://stream.binance.com/ws/btcusdt@kline_4h",
|
|
27
|
+
... should_disconnect=lambda: next_candle_in() > 30,
|
|
28
|
+
... should_reconnect=lambda: next_candle_in() < 60,
|
|
29
|
+
... )
|
|
30
|
+
... async with ws:
|
|
31
|
+
... async for candle in ws.stream():
|
|
32
|
+
... process(candle)
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import asyncio
|
|
38
|
+
import json
|
|
39
|
+
import logging
|
|
40
|
+
import time
|
|
41
|
+
from collections.abc import AsyncIterator, Awaitable, Callable, Mapping
|
|
42
|
+
from contextlib import suppress
|
|
43
|
+
from typing import TYPE_CHECKING, Any
|
|
44
|
+
|
|
45
|
+
from typing_extensions import Self
|
|
46
|
+
|
|
47
|
+
if TYPE_CHECKING:
|
|
48
|
+
import types
|
|
49
|
+
|
|
50
|
+
from kstlib.limits import get_websocket_limits
|
|
51
|
+
from kstlib.websocket.exceptions import (
|
|
52
|
+
WebSocketClosedError,
|
|
53
|
+
WebSocketConnectionError,
|
|
54
|
+
WebSocketReconnectError,
|
|
55
|
+
WebSocketTimeoutError,
|
|
56
|
+
)
|
|
57
|
+
from kstlib.websocket.models import (
|
|
58
|
+
ConnectionState,
|
|
59
|
+
DisconnectReason,
|
|
60
|
+
ReconnectStrategy,
|
|
61
|
+
WebSocketStats,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
try:
|
|
65
|
+
import websockets
|
|
66
|
+
from websockets.asyncio.client import ClientConnection, connect
|
|
67
|
+
from websockets.exceptions import (
|
|
68
|
+
ConnectionClosed,
|
|
69
|
+
ConnectionClosedError,
|
|
70
|
+
ConnectionClosedOK,
|
|
71
|
+
InvalidURI,
|
|
72
|
+
WebSocketException,
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
HAS_WEBSOCKETS = True
|
|
76
|
+
except ImportError: # pragma: no cover
|
|
77
|
+
HAS_WEBSOCKETS = False
|
|
78
|
+
websockets = None # type: ignore[assignment]
|
|
79
|
+
ClientConnection = None # type: ignore[assignment,misc]
|
|
80
|
+
connect = None # type: ignore[assignment,misc]
|
|
81
|
+
ConnectionClosed = Exception # type: ignore[assignment,misc]
|
|
82
|
+
ConnectionClosedError = Exception # type: ignore[assignment,misc]
|
|
83
|
+
ConnectionClosedOK = Exception # type: ignore[assignment,misc]
|
|
84
|
+
InvalidURI = Exception # type: ignore[assignment,misc]
|
|
85
|
+
WebSocketException = Exception # type: ignore[assignment,misc]
|
|
86
|
+
|
|
87
|
+
__all__ = ["WebSocketManager"]
|
|
88
|
+
|
|
89
|
+
log = logging.getLogger(__name__)
|
|
90
|
+
|
|
91
|
+
# Type aliases for callbacks
|
|
92
|
+
ShouldDisconnectCallback = Callable[[], bool]
|
|
93
|
+
ShouldReconnectCallback = Callable[[], bool | float]
|
|
94
|
+
OnConnectCallback = Callable[[], Awaitable[None] | None]
|
|
95
|
+
OnDisconnectCallback = Callable[[DisconnectReason], Awaitable[None] | None]
|
|
96
|
+
OnMessageCallback = Callable[[Any], Awaitable[None] | None]
|
|
97
|
+
OnAlertCallback = Callable[[str, str, Mapping[str, Any]], Awaitable[None] | None]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _check_websockets_installed() -> None:
|
|
101
|
+
"""Raise ImportError if websockets is not installed."""
|
|
102
|
+
if not HAS_WEBSOCKETS:
|
|
103
|
+
msg = "websockets is required for WebSocketManager. Install it with: pip install kstlib[websocket]"
|
|
104
|
+
raise ImportError(msg)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class WebSocketManager:
|
|
108
|
+
"""Async WebSocket manager with proactive connection control.
|
|
109
|
+
|
|
110
|
+
This manager provides both reactive (auto-reconnect on failure) and
|
|
111
|
+
proactive (user-controlled disconnect/reconnect) connection management.
|
|
112
|
+
|
|
113
|
+
The proactive control feature is the key differentiator: instead of just
|
|
114
|
+
reacting to disconnections, you can control WHEN to disconnect and reconnect.
|
|
115
|
+
This is essential for trading where you want to avoid mid-operation cuts.
|
|
116
|
+
|
|
117
|
+
Attributes:
|
|
118
|
+
url: WebSocket server URL.
|
|
119
|
+
state: Current connection state.
|
|
120
|
+
stats: Connection and message statistics.
|
|
121
|
+
|
|
122
|
+
Examples:
|
|
123
|
+
Basic streaming:
|
|
124
|
+
|
|
125
|
+
>>> async def main(): # doctest: +SKIP
|
|
126
|
+
... async with WebSocketManager("wss://example.com/ws") as ws:
|
|
127
|
+
... async for msg in ws.stream():
|
|
128
|
+
... print(msg)
|
|
129
|
+
|
|
130
|
+
Proactive control:
|
|
131
|
+
|
|
132
|
+
>>> async def trading(): # doctest: +SKIP
|
|
133
|
+
... ws = WebSocketManager(
|
|
134
|
+
... url="wss://stream.binance.com/ws",
|
|
135
|
+
... should_disconnect=lambda: not is_critical_time(),
|
|
136
|
+
... should_reconnect=lambda: is_approaching_candle(),
|
|
137
|
+
... )
|
|
138
|
+
... async with ws:
|
|
139
|
+
... await ws.subscribe("btcusdt@kline_4h")
|
|
140
|
+
... async for msg in ws.stream():
|
|
141
|
+
... process(msg)
|
|
142
|
+
"""
|
|
143
|
+
|
|
144
|
+
def __init__(
|
|
145
|
+
self,
|
|
146
|
+
url: str,
|
|
147
|
+
*,
|
|
148
|
+
# Proactive callbacks
|
|
149
|
+
should_disconnect: ShouldDisconnectCallback | None = None,
|
|
150
|
+
should_reconnect: ShouldReconnectCallback | None = None,
|
|
151
|
+
on_connect: OnConnectCallback | None = None,
|
|
152
|
+
on_disconnect: OnDisconnectCallback | None = None,
|
|
153
|
+
on_message: OnMessageCallback | None = None,
|
|
154
|
+
on_alert: OnAlertCallback | None = None,
|
|
155
|
+
# Connection settings
|
|
156
|
+
ping_interval: float | None = None,
|
|
157
|
+
ping_timeout: float | None = None,
|
|
158
|
+
connection_timeout: float | None = None,
|
|
159
|
+
# Reconnection settings
|
|
160
|
+
reconnect_strategy: ReconnectStrategy = ReconnectStrategy.EXPONENTIAL_BACKOFF,
|
|
161
|
+
reconnect_delay: float | None = None,
|
|
162
|
+
max_reconnect_delay: float | None = None,
|
|
163
|
+
max_reconnect_attempts: int | None = None,
|
|
164
|
+
auto_reconnect: bool = True,
|
|
165
|
+
# Proactive control settings
|
|
166
|
+
disconnect_check_interval: float | None = None,
|
|
167
|
+
reconnect_check_interval: float | None = None,
|
|
168
|
+
disconnect_margin: float | None = None,
|
|
169
|
+
# Queue settings
|
|
170
|
+
queue_size: int | None = None,
|
|
171
|
+
# Config
|
|
172
|
+
config: Mapping[str, Any] | None = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
"""Initialize WebSocket manager.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
url: WebSocket server URL (wss:// or ws://).
|
|
178
|
+
should_disconnect: Callback returning True when disconnect is desired.
|
|
179
|
+
should_reconnect: Callback returning True or delay (seconds) for reconnect.
|
|
180
|
+
on_connect: Callback invoked after successful connection.
|
|
181
|
+
on_disconnect: Callback invoked after disconnection with reason.
|
|
182
|
+
on_message: Callback invoked for each received message.
|
|
183
|
+
on_alert: Callback for alerting (channel, message, context).
|
|
184
|
+
ping_interval: Seconds between ping frames.
|
|
185
|
+
ping_timeout: Seconds to wait for pong response.
|
|
186
|
+
connection_timeout: Timeout for initial connection.
|
|
187
|
+
reconnect_strategy: Strategy for reconnection delays.
|
|
188
|
+
reconnect_delay: Initial delay between reconnect attempts.
|
|
189
|
+
max_reconnect_delay: Maximum delay for exponential backoff.
|
|
190
|
+
max_reconnect_attempts: Maximum consecutive reconnection attempts.
|
|
191
|
+
auto_reconnect: Whether to auto-reconnect on disconnection.
|
|
192
|
+
disconnect_check_interval: Seconds between should_disconnect checks.
|
|
193
|
+
reconnect_check_interval: Seconds between should_reconnect checks.
|
|
194
|
+
disconnect_margin: Seconds before platform limit to disconnect.
|
|
195
|
+
queue_size: Maximum messages in queue (0 = unlimited).
|
|
196
|
+
config: Optional config mapping for limits resolution.
|
|
197
|
+
|
|
198
|
+
Raises:
|
|
199
|
+
ImportError: If websockets package is not installed.
|
|
200
|
+
"""
|
|
201
|
+
_check_websockets_installed()
|
|
202
|
+
|
|
203
|
+
self._url = url
|
|
204
|
+
self._state = ConnectionState.DISCONNECTED
|
|
205
|
+
self._stats = WebSocketStats()
|
|
206
|
+
|
|
207
|
+
# Resolve limits from config with hard limit enforcement
|
|
208
|
+
limits = get_websocket_limits(config)
|
|
209
|
+
|
|
210
|
+
# Apply kwargs > config > defaults pattern
|
|
211
|
+
self._ping_interval = ping_interval if ping_interval is not None else limits.ping_interval
|
|
212
|
+
self._ping_timeout = ping_timeout if ping_timeout is not None else limits.ping_timeout
|
|
213
|
+
self._connection_timeout = connection_timeout if connection_timeout is not None else limits.connection_timeout
|
|
214
|
+
self._reconnect_delay = reconnect_delay if reconnect_delay is not None else limits.reconnect_delay
|
|
215
|
+
self._max_reconnect_delay = (
|
|
216
|
+
max_reconnect_delay if max_reconnect_delay is not None else limits.max_reconnect_delay
|
|
217
|
+
)
|
|
218
|
+
self._max_reconnect_attempts = (
|
|
219
|
+
max_reconnect_attempts if max_reconnect_attempts is not None else limits.max_reconnect_attempts
|
|
220
|
+
)
|
|
221
|
+
self._disconnect_check_interval = (
|
|
222
|
+
disconnect_check_interval if disconnect_check_interval is not None else limits.disconnect_check_interval
|
|
223
|
+
)
|
|
224
|
+
self._reconnect_check_interval = (
|
|
225
|
+
reconnect_check_interval if reconnect_check_interval is not None else limits.reconnect_check_interval
|
|
226
|
+
)
|
|
227
|
+
self._disconnect_margin = disconnect_margin if disconnect_margin is not None else limits.disconnect_margin
|
|
228
|
+
self._queue_size = queue_size if queue_size is not None else limits.queue_size
|
|
229
|
+
|
|
230
|
+
# Settings
|
|
231
|
+
self._reconnect_strategy = reconnect_strategy
|
|
232
|
+
self._auto_reconnect = auto_reconnect
|
|
233
|
+
|
|
234
|
+
# Callbacks
|
|
235
|
+
self._should_disconnect = should_disconnect
|
|
236
|
+
self._should_reconnect = should_reconnect
|
|
237
|
+
self._on_connect = on_connect
|
|
238
|
+
self._on_disconnect = on_disconnect
|
|
239
|
+
self._on_message = on_message
|
|
240
|
+
self._on_alert = on_alert
|
|
241
|
+
|
|
242
|
+
# Internal state
|
|
243
|
+
self._ws: ClientConnection | None = None
|
|
244
|
+
self._message_queue: asyncio.Queue[Any] = asyncio.Queue(maxsize=max(0, self._queue_size))
|
|
245
|
+
self._subscriptions: set[str] = set()
|
|
246
|
+
self._reconnect_count = 0
|
|
247
|
+
self._connect_time: float = 0.0
|
|
248
|
+
self._scheduled_reconnect_delay: float | None = None
|
|
249
|
+
|
|
250
|
+
# Background tasks
|
|
251
|
+
self._disconnect_check_task: asyncio.Task[None] | None = None
|
|
252
|
+
self._reconnect_check_task: asyncio.Task[None] | None = None
|
|
253
|
+
self._receive_task: asyncio.Task[None] | None = None
|
|
254
|
+
self._ping_task: asyncio.Task[None] | None = None
|
|
255
|
+
|
|
256
|
+
# Events
|
|
257
|
+
self._connected_event = asyncio.Event()
|
|
258
|
+
self._disconnected_event = asyncio.Event()
|
|
259
|
+
self._closed_event = asyncio.Event()
|
|
260
|
+
self._shutdown_event = asyncio.Event()
|
|
261
|
+
self._disconnected_event.set() # Start disconnected
|
|
262
|
+
|
|
263
|
+
@property
|
|
264
|
+
def url(self) -> str:
|
|
265
|
+
"""WebSocket server URL."""
|
|
266
|
+
return self._url
|
|
267
|
+
|
|
268
|
+
@property
|
|
269
|
+
def state(self) -> ConnectionState:
|
|
270
|
+
"""Current connection state."""
|
|
271
|
+
return self._state
|
|
272
|
+
|
|
273
|
+
@property
|
|
274
|
+
def stats(self) -> WebSocketStats:
|
|
275
|
+
"""Connection and message statistics."""
|
|
276
|
+
return self._stats
|
|
277
|
+
|
|
278
|
+
@property
|
|
279
|
+
def is_connected(self) -> bool:
|
|
280
|
+
"""Check if currently connected."""
|
|
281
|
+
return self._state == ConnectionState.CONNECTED
|
|
282
|
+
|
|
283
|
+
@property
|
|
284
|
+
def subscriptions(self) -> frozenset[str]:
|
|
285
|
+
"""Current active subscriptions."""
|
|
286
|
+
return frozenset(self._subscriptions)
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def connection_duration(self) -> float:
|
|
290
|
+
"""Seconds since last successful connection, or 0 if not connected."""
|
|
291
|
+
if self._connect_time == 0.0:
|
|
292
|
+
return 0.0
|
|
293
|
+
return time.monotonic() - self._connect_time
|
|
294
|
+
|
|
295
|
+
async def __aenter__(self) -> Self:
|
|
296
|
+
"""Enter async context and connect."""
|
|
297
|
+
await self.connect()
|
|
298
|
+
return self
|
|
299
|
+
|
|
300
|
+
async def __aexit__(
|
|
301
|
+
self,
|
|
302
|
+
exc_type: type[BaseException] | None,
|
|
303
|
+
exc_val: BaseException | None,
|
|
304
|
+
exc_tb: types.TracebackType | None,
|
|
305
|
+
) -> None:
|
|
306
|
+
"""Exit async context and close connection."""
|
|
307
|
+
await self.close()
|
|
308
|
+
|
|
309
|
+
async def connect(self) -> None:
|
|
310
|
+
"""Establish WebSocket connection.
|
|
311
|
+
|
|
312
|
+
Raises:
|
|
313
|
+
WebSocketConnectionError: If connection fails after retries.
|
|
314
|
+
WebSocketTimeoutError: If connection times out.
|
|
315
|
+
|
|
316
|
+
Examples:
|
|
317
|
+
>>> async def main(): # doctest: +SKIP
|
|
318
|
+
... ws = WebSocketManager("wss://example.com/ws")
|
|
319
|
+
... await ws.connect()
|
|
320
|
+
... try:
|
|
321
|
+
... async for msg in ws.stream():
|
|
322
|
+
... print(msg)
|
|
323
|
+
... finally:
|
|
324
|
+
... await ws.close()
|
|
325
|
+
"""
|
|
326
|
+
if not self._state.can_connect():
|
|
327
|
+
log.warning("Cannot connect from state %s", self._state)
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
self._state = ConnectionState.CONNECTING
|
|
331
|
+
self._disconnected_event.clear()
|
|
332
|
+
|
|
333
|
+
try:
|
|
334
|
+
await self._establish_connection()
|
|
335
|
+
except Exception:
|
|
336
|
+
self._state = ConnectionState.DISCONNECTED
|
|
337
|
+
self._disconnected_event.set()
|
|
338
|
+
raise
|
|
339
|
+
|
|
340
|
+
async def _try_connect(self) -> ClientConnection:
|
|
341
|
+
"""Attempt a single connection with timeout."""
|
|
342
|
+
return await asyncio.wait_for(
|
|
343
|
+
connect(
|
|
344
|
+
self._url,
|
|
345
|
+
ping_interval=self._ping_interval,
|
|
346
|
+
ping_timeout=self._ping_timeout,
|
|
347
|
+
),
|
|
348
|
+
timeout=self._connection_timeout,
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
async def _establish_connection(self) -> None:
|
|
352
|
+
"""Internal connection establishment with timeout."""
|
|
353
|
+
last_error: BaseException | None = None
|
|
354
|
+
|
|
355
|
+
for attempt in range(1, self._max_reconnect_attempts + 2):
|
|
356
|
+
try:
|
|
357
|
+
self._ws = await self._try_connect()
|
|
358
|
+
break
|
|
359
|
+
except asyncio.TimeoutError as e:
|
|
360
|
+
last_error = e
|
|
361
|
+
log.warning("Connection timeout (attempt %d/%d)", attempt, self._max_reconnect_attempts + 1)
|
|
362
|
+
except InvalidURI as e:
|
|
363
|
+
raise WebSocketConnectionError(
|
|
364
|
+
f"Invalid WebSocket URL: {self._url}",
|
|
365
|
+
url=self._url,
|
|
366
|
+
attempts=attempt,
|
|
367
|
+
last_error=e,
|
|
368
|
+
) from e
|
|
369
|
+
except (OSError, WebSocketException) as e:
|
|
370
|
+
last_error = e
|
|
371
|
+
log.warning("Connection failed (attempt %d/%d): %s", attempt, self._max_reconnect_attempts + 1, e)
|
|
372
|
+
|
|
373
|
+
if attempt <= self._max_reconnect_attempts:
|
|
374
|
+
await self._wait_reconnect_delay(attempt)
|
|
375
|
+
else:
|
|
376
|
+
self._raise_connection_failed(last_error, self._max_reconnect_attempts + 1)
|
|
377
|
+
|
|
378
|
+
# Connection successful
|
|
379
|
+
await self._finalize_connection()
|
|
380
|
+
|
|
381
|
+
def _raise_connection_failed(self, last_error: BaseException | None, attempts: int) -> None:
|
|
382
|
+
"""Raise appropriate connection error after all attempts exhausted."""
|
|
383
|
+
if isinstance(last_error, asyncio.TimeoutError):
|
|
384
|
+
raise WebSocketTimeoutError(
|
|
385
|
+
f"Connection timed out after {attempts} attempts",
|
|
386
|
+
operation="connect",
|
|
387
|
+
timeout=self._connection_timeout,
|
|
388
|
+
) from last_error
|
|
389
|
+
raise WebSocketConnectionError(
|
|
390
|
+
f"Failed to connect after {attempts} attempts",
|
|
391
|
+
url=self._url,
|
|
392
|
+
attempts=attempts,
|
|
393
|
+
last_error=last_error,
|
|
394
|
+
) from last_error
|
|
395
|
+
|
|
396
|
+
async def _finalize_connection(self) -> None:
|
|
397
|
+
"""Finalize successful connection setup."""
|
|
398
|
+
self._state = ConnectionState.CONNECTED
|
|
399
|
+
self._connected_event.set()
|
|
400
|
+
self._connect_time = time.monotonic()
|
|
401
|
+
self._reconnect_count = 0
|
|
402
|
+
self._stats.record_connect()
|
|
403
|
+
|
|
404
|
+
log.info("WebSocket connected to %s", self._url)
|
|
405
|
+
|
|
406
|
+
# Start background tasks
|
|
407
|
+
self._start_background_tasks()
|
|
408
|
+
|
|
409
|
+
# Invoke on_connect callback
|
|
410
|
+
if self._on_connect is not None:
|
|
411
|
+
result = self._on_connect()
|
|
412
|
+
if asyncio.iscoroutine(result):
|
|
413
|
+
await result
|
|
414
|
+
|
|
415
|
+
# Re-subscribe to channels
|
|
416
|
+
if self._subscriptions:
|
|
417
|
+
await self._resubscribe()
|
|
418
|
+
|
|
419
|
+
def _start_background_tasks(self) -> None:
|
|
420
|
+
"""Start background monitoring tasks."""
|
|
421
|
+
# Start receive task
|
|
422
|
+
self._receive_task = asyncio.create_task(self._receive_loop(), name="ws_receive_loop")
|
|
423
|
+
|
|
424
|
+
# Start proactive disconnect check if callback provided
|
|
425
|
+
if self._should_disconnect is not None:
|
|
426
|
+
self._disconnect_check_task = asyncio.create_task(self._disconnect_check_loop(), name="ws_disconnect_check")
|
|
427
|
+
|
|
428
|
+
def _parse_message(self, message: str | bytes) -> Any:
|
|
429
|
+
"""Parse incoming message, attempting JSON decode for strings."""
|
|
430
|
+
if isinstance(message, str):
|
|
431
|
+
try:
|
|
432
|
+
return json.loads(message)
|
|
433
|
+
except json.JSONDecodeError:
|
|
434
|
+
return message
|
|
435
|
+
return message
|
|
436
|
+
|
|
437
|
+
async def _process_message(self, message: str | bytes) -> None:
|
|
438
|
+
"""Process a single received message."""
|
|
439
|
+
data = self._parse_message(message)
|
|
440
|
+
size = len(message) if isinstance(message, str | bytes) else 0
|
|
441
|
+
self._stats.record_message_received(size)
|
|
442
|
+
|
|
443
|
+
# Invoke on_message callback
|
|
444
|
+
if self._on_message is not None:
|
|
445
|
+
result = self._on_message(data)
|
|
446
|
+
if asyncio.iscoroutine(result):
|
|
447
|
+
await result
|
|
448
|
+
|
|
449
|
+
# Queue message
|
|
450
|
+
try:
|
|
451
|
+
self._message_queue.put_nowait(data)
|
|
452
|
+
except asyncio.QueueFull:
|
|
453
|
+
await self._handle_queue_full()
|
|
454
|
+
|
|
455
|
+
async def _handle_queue_full(self) -> None:
|
|
456
|
+
"""Handle queue overflow situation."""
|
|
457
|
+
log.warning("Message queue full, dropping message")
|
|
458
|
+
if self._on_alert:
|
|
459
|
+
alert_result = self._on_alert(
|
|
460
|
+
"websocket",
|
|
461
|
+
"Message queue full",
|
|
462
|
+
{"queue_size": self._queue_size},
|
|
463
|
+
)
|
|
464
|
+
if asyncio.iscoroutine(alert_result):
|
|
465
|
+
await alert_result
|
|
466
|
+
|
|
467
|
+
async def _receive_loop(self) -> None:
|
|
468
|
+
"""Background task to receive messages and queue them."""
|
|
469
|
+
if self._ws is None:
|
|
470
|
+
return
|
|
471
|
+
|
|
472
|
+
try:
|
|
473
|
+
async for message in self._ws:
|
|
474
|
+
await self._process_message(message)
|
|
475
|
+
except ConnectionClosedOK:
|
|
476
|
+
log.debug("WebSocket closed normally")
|
|
477
|
+
except ConnectionClosedError as e:
|
|
478
|
+
log.warning("WebSocket closed with error: code=%d reason=%s", e.code, e.reason)
|
|
479
|
+
await self._handle_disconnect(DisconnectReason.SERVER_CLOSED, code=e.code)
|
|
480
|
+
except ConnectionClosed as e:
|
|
481
|
+
log.warning("WebSocket connection closed: %s", e)
|
|
482
|
+
await self._handle_disconnect(DisconnectReason.SERVER_CLOSED)
|
|
483
|
+
except Exception:
|
|
484
|
+
log.exception("Unexpected error in receive loop")
|
|
485
|
+
await self._handle_disconnect(DisconnectReason.PROTOCOL_ERROR)
|
|
486
|
+
|
|
487
|
+
async def _disconnect_check_loop(self) -> None:
|
|
488
|
+
"""Background task to check should_disconnect callback."""
|
|
489
|
+
while self._state == ConnectionState.CONNECTED and not self._shutdown_event.is_set():
|
|
490
|
+
await asyncio.sleep(self._disconnect_check_interval)
|
|
491
|
+
|
|
492
|
+
if self._shutdown_event.is_set():
|
|
493
|
+
break
|
|
494
|
+
|
|
495
|
+
if self._should_disconnect is not None and self._state == ConnectionState.CONNECTED:
|
|
496
|
+
try:
|
|
497
|
+
should_disconnect = self._should_disconnect()
|
|
498
|
+
if should_disconnect:
|
|
499
|
+
log.info("should_disconnect callback returned True, disconnecting")
|
|
500
|
+
await self.request_disconnect(reason=DisconnectReason.CALLBACK_TRIGGERED)
|
|
501
|
+
break
|
|
502
|
+
except Exception as e:
|
|
503
|
+
log.warning("Error in should_disconnect callback: %s", e)
|
|
504
|
+
|
|
505
|
+
async def _handle_disconnect(
|
|
506
|
+
self,
|
|
507
|
+
reason: DisconnectReason,
|
|
508
|
+
*,
|
|
509
|
+
code: int = 1006,
|
|
510
|
+
) -> None:
|
|
511
|
+
"""Handle disconnection and potentially reconnect."""
|
|
512
|
+
was_connected = self._state == ConnectionState.CONNECTED
|
|
513
|
+
self._state = ConnectionState.RECONNECTING if self._auto_reconnect else ConnectionState.DISCONNECTED
|
|
514
|
+
self._connected_event.clear()
|
|
515
|
+
|
|
516
|
+
if was_connected:
|
|
517
|
+
self._stats.record_disconnect(proactive=reason.is_proactive)
|
|
518
|
+
|
|
519
|
+
# Cancel background tasks
|
|
520
|
+
await self._cancel_background_tasks()
|
|
521
|
+
|
|
522
|
+
# Invoke on_disconnect callback
|
|
523
|
+
if self._on_disconnect is not None:
|
|
524
|
+
result = self._on_disconnect(reason)
|
|
525
|
+
if asyncio.iscoroutine(result):
|
|
526
|
+
await result
|
|
527
|
+
|
|
528
|
+
log.info("WebSocket disconnected: %s (code=%d)", reason.name, code)
|
|
529
|
+
|
|
530
|
+
# Handle reconnection
|
|
531
|
+
if self._auto_reconnect and not reason.is_proactive:
|
|
532
|
+
# Reactive reconnect for forced disconnections
|
|
533
|
+
await self._attempt_reconnect()
|
|
534
|
+
elif reason.is_proactive and self._scheduled_reconnect_delay is not None:
|
|
535
|
+
# Scheduled reconnect after proactive disconnect
|
|
536
|
+
delay = self._scheduled_reconnect_delay
|
|
537
|
+
self._scheduled_reconnect_delay = None
|
|
538
|
+
await asyncio.sleep(delay)
|
|
539
|
+
await self._attempt_reconnect()
|
|
540
|
+
elif reason.is_proactive and self._should_reconnect is not None:
|
|
541
|
+
# Callback-controlled reconnect
|
|
542
|
+
self._reconnect_check_task = asyncio.create_task(self._reconnect_check_loop(), name="ws_reconnect_check")
|
|
543
|
+
else:
|
|
544
|
+
self._state = ConnectionState.DISCONNECTED
|
|
545
|
+
self._disconnected_event.set()
|
|
546
|
+
|
|
547
|
+
async def _reconnect_check_loop(self) -> None:
|
|
548
|
+
"""Background task to check should_reconnect callback."""
|
|
549
|
+
while self._state == ConnectionState.RECONNECTING and not self._shutdown_event.is_set():
|
|
550
|
+
await asyncio.sleep(self._reconnect_check_interval)
|
|
551
|
+
|
|
552
|
+
if self._shutdown_event.is_set():
|
|
553
|
+
break
|
|
554
|
+
|
|
555
|
+
if self._state not in (ConnectionState.RECONNECTING, ConnectionState.DISCONNECTED):
|
|
556
|
+
break
|
|
557
|
+
|
|
558
|
+
if self._should_reconnect is not None:
|
|
559
|
+
try:
|
|
560
|
+
result = self._should_reconnect()
|
|
561
|
+
if result is True:
|
|
562
|
+
log.info("should_reconnect callback returned True, reconnecting")
|
|
563
|
+
await self._attempt_reconnect()
|
|
564
|
+
break
|
|
565
|
+
if isinstance(result, int | float) and result > 0:
|
|
566
|
+
log.info(
|
|
567
|
+
"should_reconnect callback returned delay=%.1fs, scheduling",
|
|
568
|
+
result,
|
|
569
|
+
)
|
|
570
|
+
await asyncio.sleep(result)
|
|
571
|
+
await self._attempt_reconnect()
|
|
572
|
+
break
|
|
573
|
+
except Exception as e:
|
|
574
|
+
log.warning("Error in should_reconnect callback: %s", e)
|
|
575
|
+
|
|
576
|
+
async def _attempt_reconnect(self) -> None:
|
|
577
|
+
"""Attempt to reconnect with retry logic."""
|
|
578
|
+
if self._shutdown_event.is_set():
|
|
579
|
+
log.debug("Shutdown requested, skipping reconnect attempt")
|
|
580
|
+
return
|
|
581
|
+
|
|
582
|
+
self._reconnect_count += 1
|
|
583
|
+
|
|
584
|
+
if self._reconnect_count > self._max_reconnect_attempts:
|
|
585
|
+
log.error(
|
|
586
|
+
"Max reconnection attempts (%d) exceeded",
|
|
587
|
+
self._max_reconnect_attempts,
|
|
588
|
+
)
|
|
589
|
+
self._state = ConnectionState.DISCONNECTED
|
|
590
|
+
self._disconnected_event.set()
|
|
591
|
+
raise WebSocketReconnectError(
|
|
592
|
+
f"Failed to reconnect after {self._reconnect_count} attempts",
|
|
593
|
+
attempts=self._reconnect_count,
|
|
594
|
+
)
|
|
595
|
+
|
|
596
|
+
log.info(
|
|
597
|
+
"Attempting reconnection (%d/%d)",
|
|
598
|
+
self._reconnect_count,
|
|
599
|
+
self._max_reconnect_attempts,
|
|
600
|
+
)
|
|
601
|
+
|
|
602
|
+
self._state = ConnectionState.CONNECTING
|
|
603
|
+
try:
|
|
604
|
+
await self._establish_connection()
|
|
605
|
+
except WebSocketConnectionError:
|
|
606
|
+
if self._shutdown_event.is_set():
|
|
607
|
+
return
|
|
608
|
+
self._state = ConnectionState.RECONNECTING
|
|
609
|
+
await self._wait_reconnect_delay(self._reconnect_count)
|
|
610
|
+
await self._attempt_reconnect()
|
|
611
|
+
|
|
612
|
+
async def _wait_reconnect_delay(self, attempt: int) -> None:
|
|
613
|
+
"""Wait before next reconnection attempt based on strategy."""
|
|
614
|
+
if self._reconnect_strategy == ReconnectStrategy.IMMEDIATE:
|
|
615
|
+
return
|
|
616
|
+
if self._reconnect_strategy == ReconnectStrategy.FIXED_DELAY:
|
|
617
|
+
delay = self._reconnect_delay
|
|
618
|
+
elif self._reconnect_strategy == ReconnectStrategy.EXPONENTIAL_BACKOFF:
|
|
619
|
+
delay = min(
|
|
620
|
+
self._reconnect_delay * (2 ** (attempt - 1)),
|
|
621
|
+
self._max_reconnect_delay,
|
|
622
|
+
)
|
|
623
|
+
else: # CALLBACK_CONTROLLED
|
|
624
|
+
delay = self._reconnect_delay
|
|
625
|
+
|
|
626
|
+
log.debug("Waiting %.1fs before reconnection attempt", delay)
|
|
627
|
+
await asyncio.sleep(delay)
|
|
628
|
+
|
|
629
|
+
async def _cancel_background_tasks(self) -> None:
|
|
630
|
+
"""Cancel all background tasks."""
|
|
631
|
+
tasks = [
|
|
632
|
+
self._receive_task,
|
|
633
|
+
self._disconnect_check_task,
|
|
634
|
+
self._reconnect_check_task,
|
|
635
|
+
self._ping_task,
|
|
636
|
+
]
|
|
637
|
+
for task in tasks:
|
|
638
|
+
if task is not None and not task.done():
|
|
639
|
+
task.cancel()
|
|
640
|
+
with suppress(asyncio.CancelledError):
|
|
641
|
+
await task
|
|
642
|
+
|
|
643
|
+
self._receive_task = None
|
|
644
|
+
self._disconnect_check_task = None
|
|
645
|
+
self._reconnect_check_task = None
|
|
646
|
+
self._ping_task = None
|
|
647
|
+
|
|
648
|
+
async def _resubscribe(self) -> None:
|
|
649
|
+
"""Re-subscribe to all channels after reconnection."""
|
|
650
|
+
if not self._subscriptions:
|
|
651
|
+
return
|
|
652
|
+
|
|
653
|
+
log.debug("Re-subscribing to %d channels", len(self._subscriptions))
|
|
654
|
+
for channel in self._subscriptions:
|
|
655
|
+
try:
|
|
656
|
+
await self._send_subscribe(channel)
|
|
657
|
+
except Exception as e:
|
|
658
|
+
log.warning("Failed to re-subscribe to %s: %s", channel, e)
|
|
659
|
+
|
|
660
|
+
async def _send_subscribe(self, channel: str) -> None:
|
|
661
|
+
"""Send subscription message for a channel."""
|
|
662
|
+
if self._ws is None:
|
|
663
|
+
return
|
|
664
|
+
|
|
665
|
+
# Generic subscription format - can be overridden for specific protocols
|
|
666
|
+
message = json.dumps({"type": "subscribe", "channel": channel})
|
|
667
|
+
await self._ws.send(message)
|
|
668
|
+
|
|
669
|
+
# =========================================================================
|
|
670
|
+
# PUBLIC PROACTIVE CONTROL METHODS
|
|
671
|
+
# =========================================================================
|
|
672
|
+
|
|
673
|
+
async def request_disconnect(
|
|
674
|
+
self,
|
|
675
|
+
*,
|
|
676
|
+
reconnect_after: float | None = None,
|
|
677
|
+
reason: DisconnectReason = DisconnectReason.USER_REQUESTED,
|
|
678
|
+
) -> None:
|
|
679
|
+
"""Request a controlled disconnection.
|
|
680
|
+
|
|
681
|
+
This is a proactive method that allows the user to disconnect
|
|
682
|
+
at a time of their choosing rather than waiting for a forced cut.
|
|
683
|
+
|
|
684
|
+
Args:
|
|
685
|
+
reconnect_after: Optional delay before auto-reconnect (seconds).
|
|
686
|
+
reason: Reason for the disconnection.
|
|
687
|
+
|
|
688
|
+
Examples:
|
|
689
|
+
>>> async def main(): # doctest: +SKIP
|
|
690
|
+
... async with WebSocketManager("wss://example.com/ws") as ws:
|
|
691
|
+
... # Disconnect now, reconnect in 5 minutes
|
|
692
|
+
... await ws.request_disconnect(reconnect_after=300)
|
|
693
|
+
"""
|
|
694
|
+
if self._state != ConnectionState.CONNECTED:
|
|
695
|
+
log.warning("Cannot disconnect from state %s", self._state)
|
|
696
|
+
return
|
|
697
|
+
|
|
698
|
+
log.info("User requested disconnect (reconnect_after=%s)", reconnect_after)
|
|
699
|
+
self._scheduled_reconnect_delay = reconnect_after
|
|
700
|
+
|
|
701
|
+
# Close the connection gracefully
|
|
702
|
+
if self._ws is not None:
|
|
703
|
+
await self._ws.close(1000, "User requested disconnect")
|
|
704
|
+
|
|
705
|
+
await self._handle_disconnect(reason)
|
|
706
|
+
|
|
707
|
+
async def schedule_reconnect(self, delay: float) -> None:
|
|
708
|
+
"""Schedule a reconnection after a delay.
|
|
709
|
+
|
|
710
|
+
Use this to programmatically reconnect after a proactive disconnect.
|
|
711
|
+
|
|
712
|
+
Args:
|
|
713
|
+
delay: Seconds to wait before reconnecting.
|
|
714
|
+
|
|
715
|
+
Examples:
|
|
716
|
+
>>> async def main(): # doctest: +SKIP
|
|
717
|
+
... ws = WebSocketManager("wss://example.com/ws")
|
|
718
|
+
... await ws.connect()
|
|
719
|
+
... await ws.request_disconnect()
|
|
720
|
+
... # Reconnect in 5 minutes
|
|
721
|
+
... await ws.schedule_reconnect(300)
|
|
722
|
+
"""
|
|
723
|
+
if self._state not in (ConnectionState.DISCONNECTED, ConnectionState.RECONNECTING):
|
|
724
|
+
log.warning("Cannot schedule reconnect from state %s", self._state)
|
|
725
|
+
return
|
|
726
|
+
|
|
727
|
+
log.info("Scheduling reconnection in %.1f seconds", delay)
|
|
728
|
+
self._state = ConnectionState.RECONNECTING
|
|
729
|
+
await asyncio.sleep(delay)
|
|
730
|
+
await self._attempt_reconnect()
|
|
731
|
+
|
|
732
|
+
async def wait_for_reconnect_window(
|
|
733
|
+
self,
|
|
734
|
+
should_reconnect: ShouldReconnectCallback | None = None,
|
|
735
|
+
timeout: float | None = None,
|
|
736
|
+
) -> bool:
|
|
737
|
+
"""Wait until the reconnection condition is met.
|
|
738
|
+
|
|
739
|
+
Args:
|
|
740
|
+
should_reconnect: Custom callback (overrides instance callback).
|
|
741
|
+
timeout: Maximum time to wait (seconds).
|
|
742
|
+
|
|
743
|
+
Returns:
|
|
744
|
+
True if reconnect condition was met, False if timed out.
|
|
745
|
+
|
|
746
|
+
Examples:
|
|
747
|
+
>>> async def main(): # doctest: +SKIP
|
|
748
|
+
... ws = WebSocketManager("wss://example.com/ws")
|
|
749
|
+
... await ws.connect()
|
|
750
|
+
... await ws.request_disconnect()
|
|
751
|
+
... # Wait for trading window
|
|
752
|
+
... if await ws.wait_for_reconnect_window(
|
|
753
|
+
... should_reconnect=lambda: is_trading_time(),
|
|
754
|
+
... timeout=3600,
|
|
755
|
+
... ):
|
|
756
|
+
... await ws.connect()
|
|
757
|
+
"""
|
|
758
|
+
callback = should_reconnect or self._should_reconnect
|
|
759
|
+
if callback is None:
|
|
760
|
+
log.warning("No should_reconnect callback provided")
|
|
761
|
+
return False
|
|
762
|
+
|
|
763
|
+
start_time = time.monotonic()
|
|
764
|
+
while True:
|
|
765
|
+
elapsed = time.monotonic() - start_time
|
|
766
|
+
if timeout is not None and elapsed >= timeout:
|
|
767
|
+
return False
|
|
768
|
+
|
|
769
|
+
try:
|
|
770
|
+
result = callback()
|
|
771
|
+
if result is True:
|
|
772
|
+
return True
|
|
773
|
+
if isinstance(result, int | float) and result > 0:
|
|
774
|
+
wait_time = min(
|
|
775
|
+
result,
|
|
776
|
+
(timeout - elapsed) if timeout else result,
|
|
777
|
+
)
|
|
778
|
+
await asyncio.sleep(wait_time)
|
|
779
|
+
continue
|
|
780
|
+
except Exception as e:
|
|
781
|
+
log.warning("Error in should_reconnect callback: %s", e)
|
|
782
|
+
|
|
783
|
+
await asyncio.sleep(self._reconnect_check_interval)
|
|
784
|
+
|
|
785
|
+
async def force_close(self) -> None:
|
|
786
|
+
"""Emergency close without reconnection (terminal state).
|
|
787
|
+
|
|
788
|
+
This is for emergency situations where you need to stop immediately.
|
|
789
|
+
The WebSocket instance becomes unusable after this call.
|
|
790
|
+
|
|
791
|
+
**Key difference from kill() and shutdown():**
|
|
792
|
+
- `kill()`: Reactive. Server kicked us. State=DISCONNECTED. CAN reconnect.
|
|
793
|
+
- `shutdown()`: Proactive graceful. User wants to stop. State=CLOSED. CANNOT reconnect.
|
|
794
|
+
- `force_close()`: Emergency stop. State=CLOSED. CANNOT reconnect.
|
|
795
|
+
|
|
796
|
+
Examples:
|
|
797
|
+
>>> async def main(): # doctest: +SKIP
|
|
798
|
+
... ws = WebSocketManager("wss://example.com/ws")
|
|
799
|
+
... await ws.connect()
|
|
800
|
+
... await ws.force_close()
|
|
801
|
+
... # Cannot reconnect after force_close
|
|
802
|
+
... assert ws.state == ConnectionState.CLOSED
|
|
803
|
+
"""
|
|
804
|
+
self._auto_reconnect = False
|
|
805
|
+
self._state = ConnectionState.CLOSING
|
|
806
|
+
|
|
807
|
+
await self._cancel_background_tasks()
|
|
808
|
+
|
|
809
|
+
if self._ws is not None:
|
|
810
|
+
await self._ws.close(1000, "Force close")
|
|
811
|
+
self._ws = None
|
|
812
|
+
|
|
813
|
+
self._state = ConnectionState.CLOSED
|
|
814
|
+
self._closed_event.set()
|
|
815
|
+
self._disconnected_event.set()
|
|
816
|
+
|
|
817
|
+
log.info("WebSocket force closed")
|
|
818
|
+
|
|
819
|
+
async def kill(self) -> None:
|
|
820
|
+
"""Simulate external disconnection (reactive, we are the victim).
|
|
821
|
+
|
|
822
|
+
This simulates a scenario where the server (e.g., Binance) forcefully
|
|
823
|
+
disconnects us. The WebSocket "suffers" this disconnection.
|
|
824
|
+
|
|
825
|
+
**Key difference from force_close() and shutdown():**
|
|
826
|
+
- `kill()`: Reactive. Server kicked us. State=DISCONNECTED. CAN reconnect.
|
|
827
|
+
- `shutdown()`: Proactive graceful. User wants to stop. State=CLOSED. CANNOT reconnect.
|
|
828
|
+
- `force_close()`: Emergency stop. State=CLOSED. CANNOT reconnect.
|
|
829
|
+
|
|
830
|
+
Use this to test heartbeat/watchdog recovery mechanisms.
|
|
831
|
+
|
|
832
|
+
Examples:
|
|
833
|
+
>>> async def main(): # doctest: +SKIP
|
|
834
|
+
... ws = WebSocketManager("wss://example.com/ws", auto_reconnect=False)
|
|
835
|
+
... await ws.connect()
|
|
836
|
+
... await ws.kill() # Simulates Binance kicking us
|
|
837
|
+
... assert ws.state == ConnectionState.DISCONNECTED
|
|
838
|
+
... # Heartbeat detects is_dead=True and can restart
|
|
839
|
+
"""
|
|
840
|
+
if self._state == ConnectionState.CLOSED:
|
|
841
|
+
log.warning("Cannot kill: already closed")
|
|
842
|
+
return
|
|
843
|
+
|
|
844
|
+
log.info("Killing WebSocket (simulating external disconnect)")
|
|
845
|
+
|
|
846
|
+
# Cancel background tasks first
|
|
847
|
+
await self._cancel_background_tasks()
|
|
848
|
+
|
|
849
|
+
# Close the raw connection if open
|
|
850
|
+
if self._ws is not None:
|
|
851
|
+
with suppress(Exception):
|
|
852
|
+
await self._ws.close(1006, "Killed")
|
|
853
|
+
self._ws = None
|
|
854
|
+
|
|
855
|
+
# Move to DISCONNECTED (not CLOSED) so reconnection is possible
|
|
856
|
+
self._state = ConnectionState.DISCONNECTED
|
|
857
|
+
self._connected_event.clear()
|
|
858
|
+
self._disconnected_event.set()
|
|
859
|
+
|
|
860
|
+
# Record as reactive disconnect (forced)
|
|
861
|
+
self._stats.record_disconnect(proactive=False)
|
|
862
|
+
|
|
863
|
+
# Invoke on_disconnect callback with KILLED reason
|
|
864
|
+
if self._on_disconnect is not None:
|
|
865
|
+
result = self._on_disconnect(DisconnectReason.KILLED)
|
|
866
|
+
if asyncio.iscoroutine(result):
|
|
867
|
+
await result
|
|
868
|
+
|
|
869
|
+
async def shutdown(self) -> None:
|
|
870
|
+
"""Graceful intentional shutdown (proactive, user-initiated).
|
|
871
|
+
|
|
872
|
+
This is for clean shutdown scenarios like CTRL+C or service stop.
|
|
873
|
+
The WebSocket proactively decides to close.
|
|
874
|
+
|
|
875
|
+
**Key difference from kill() and force_close():**
|
|
876
|
+
- `kill()`: Reactive. Server kicked us. State=DISCONNECTED. CAN reconnect.
|
|
877
|
+
- `shutdown()`: Proactive graceful. User wants to stop. State=CLOSED. CANNOT reconnect.
|
|
878
|
+
- `force_close()`: Emergency stop. State=CLOSED. CANNOT reconnect.
|
|
879
|
+
|
|
880
|
+
Sets the shutdown event so external code (heartbeat, watchdog) knows
|
|
881
|
+
we're stopping intentionally and should not try to restart us.
|
|
882
|
+
|
|
883
|
+
Examples:
|
|
884
|
+
>>> async def main(): # doctest: +SKIP
|
|
885
|
+
... ws = WebSocketManager("wss://example.com/ws")
|
|
886
|
+
... await ws.connect()
|
|
887
|
+
... # In SIGINT handler:
|
|
888
|
+
... await ws.shutdown()
|
|
889
|
+
... assert ws.is_shutdown # Heartbeat knows not to restart
|
|
890
|
+
"""
|
|
891
|
+
log.info("WebSocket shutdown requested")
|
|
892
|
+
self._shutdown_event.set()
|
|
893
|
+
await self.force_close()
|
|
894
|
+
|
|
895
|
+
@property
|
|
896
|
+
def is_shutdown(self) -> bool:
|
|
897
|
+
"""Check if shutdown has been requested."""
|
|
898
|
+
return self._shutdown_event.is_set()
|
|
899
|
+
|
|
900
|
+
@property
|
|
901
|
+
def is_dead(self) -> bool:
|
|
902
|
+
"""Check if connection is dead (not connected and not reconnecting).
|
|
903
|
+
|
|
904
|
+
Useful for heartbeat monitoring to detect if the WebSocket needs restart.
|
|
905
|
+
|
|
906
|
+
Returns:
|
|
907
|
+
True if connection is in a dead state requiring manual intervention.
|
|
908
|
+
|
|
909
|
+
Examples:
|
|
910
|
+
>>> async def main(): # doctest: +SKIP
|
|
911
|
+
... ws = WebSocketManager("wss://example.com/ws")
|
|
912
|
+
... if ws.is_dead:
|
|
913
|
+
... await ws.connect() # Restart
|
|
914
|
+
"""
|
|
915
|
+
return self._state in (
|
|
916
|
+
ConnectionState.DISCONNECTED,
|
|
917
|
+
ConnectionState.CLOSED,
|
|
918
|
+
)
|
|
919
|
+
|
|
920
|
+
async def close(self) -> None:
|
|
921
|
+
"""Gracefully close the connection.
|
|
922
|
+
|
|
923
|
+
Alias for force_close() when used in context manager.
|
|
924
|
+
"""
|
|
925
|
+
await self.force_close()
|
|
926
|
+
|
|
927
|
+
# =========================================================================
|
|
928
|
+
# MESSAGING METHODS
|
|
929
|
+
# =========================================================================
|
|
930
|
+
|
|
931
|
+
async def send(self, data: Any) -> None:
|
|
932
|
+
"""Send a message to the WebSocket server.
|
|
933
|
+
|
|
934
|
+
Args:
|
|
935
|
+
data: Data to send (dict/list will be JSON-encoded).
|
|
936
|
+
|
|
937
|
+
Raises:
|
|
938
|
+
WebSocketClosedError: If connection is not active.
|
|
939
|
+
|
|
940
|
+
Examples:
|
|
941
|
+
>>> async def main(): # doctest: +SKIP
|
|
942
|
+
... async with WebSocketManager("wss://example.com/ws") as ws:
|
|
943
|
+
... await ws.send({"type": "ping"})
|
|
944
|
+
"""
|
|
945
|
+
if self._ws is None or not self._state.can_send():
|
|
946
|
+
raise WebSocketClosedError(
|
|
947
|
+
"Cannot send: connection not active",
|
|
948
|
+
code=1006,
|
|
949
|
+
reason="Not connected",
|
|
950
|
+
)
|
|
951
|
+
|
|
952
|
+
message = json.dumps(data) if isinstance(data, dict | list) else str(data)
|
|
953
|
+
await self._ws.send(message)
|
|
954
|
+
self._stats.record_message_sent(len(message))
|
|
955
|
+
|
|
956
|
+
async def receive(self, timeout: float | None = None) -> Any:
|
|
957
|
+
"""Receive a single message from the queue.
|
|
958
|
+
|
|
959
|
+
Args:
|
|
960
|
+
timeout: Maximum time to wait (seconds).
|
|
961
|
+
|
|
962
|
+
Returns:
|
|
963
|
+
The received message.
|
|
964
|
+
|
|
965
|
+
Raises:
|
|
966
|
+
WebSocketTimeoutError: If timeout expires.
|
|
967
|
+
WebSocketClosedError: If connection is closed.
|
|
968
|
+
|
|
969
|
+
Examples:
|
|
970
|
+
>>> async def main(): # doctest: +SKIP
|
|
971
|
+
... async with WebSocketManager("wss://example.com/ws") as ws:
|
|
972
|
+
... msg = await ws.receive(timeout=10)
|
|
973
|
+
"""
|
|
974
|
+
try:
|
|
975
|
+
if timeout is not None:
|
|
976
|
+
return await asyncio.wait_for(self._message_queue.get(), timeout=timeout)
|
|
977
|
+
return await self._message_queue.get()
|
|
978
|
+
except asyncio.TimeoutError as e:
|
|
979
|
+
raise WebSocketTimeoutError(
|
|
980
|
+
"Receive timed out",
|
|
981
|
+
operation="receive",
|
|
982
|
+
timeout=timeout or 0,
|
|
983
|
+
) from e
|
|
984
|
+
|
|
985
|
+
async def stream(self) -> AsyncIterator[Any]:
|
|
986
|
+
"""Iterate over received messages.
|
|
987
|
+
|
|
988
|
+
This is the main method for consuming WebSocket messages.
|
|
989
|
+
It handles reconnection transparently.
|
|
990
|
+
|
|
991
|
+
Yields:
|
|
992
|
+
Received messages (parsed JSON or raw string).
|
|
993
|
+
|
|
994
|
+
Examples:
|
|
995
|
+
>>> async def main(): # doctest: +SKIP
|
|
996
|
+
... async with WebSocketManager("wss://example.com/ws") as ws:
|
|
997
|
+
... async for msg in ws.stream():
|
|
998
|
+
... print(msg)
|
|
999
|
+
"""
|
|
1000
|
+
while self._state not in (ConnectionState.CLOSED, ConnectionState.CLOSING):
|
|
1001
|
+
if self._shutdown_event.is_set():
|
|
1002
|
+
break
|
|
1003
|
+
|
|
1004
|
+
try:
|
|
1005
|
+
# Wait for connection if disconnected
|
|
1006
|
+
if not self.is_connected:
|
|
1007
|
+
if self._state == ConnectionState.CLOSED:
|
|
1008
|
+
break
|
|
1009
|
+
await self._connected_event.wait()
|
|
1010
|
+
|
|
1011
|
+
# Get message with timeout to allow state checking
|
|
1012
|
+
try:
|
|
1013
|
+
message = await asyncio.wait_for(
|
|
1014
|
+
self._message_queue.get(),
|
|
1015
|
+
timeout=1.0,
|
|
1016
|
+
)
|
|
1017
|
+
yield message
|
|
1018
|
+
except asyncio.TimeoutError:
|
|
1019
|
+
continue
|
|
1020
|
+
|
|
1021
|
+
except asyncio.CancelledError:
|
|
1022
|
+
break
|
|
1023
|
+
|
|
1024
|
+
# =========================================================================
|
|
1025
|
+
# SUBSCRIPTION METHODS
|
|
1026
|
+
# =========================================================================
|
|
1027
|
+
|
|
1028
|
+
async def subscribe(self, *channels: str) -> None:
|
|
1029
|
+
"""Subscribe to one or more channels.
|
|
1030
|
+
|
|
1031
|
+
Subscriptions are automatically restored after reconnection.
|
|
1032
|
+
|
|
1033
|
+
Args:
|
|
1034
|
+
channels: Channel names to subscribe to.
|
|
1035
|
+
|
|
1036
|
+
Examples:
|
|
1037
|
+
>>> async def main(): # doctest: +SKIP
|
|
1038
|
+
... async with WebSocketManager("wss://stream.binance.com/ws") as ws:
|
|
1039
|
+
... await ws.subscribe("btcusdt@trade", "ethusdt@trade")
|
|
1040
|
+
"""
|
|
1041
|
+
for channel in channels:
|
|
1042
|
+
if channel not in self._subscriptions:
|
|
1043
|
+
self._subscriptions.add(channel)
|
|
1044
|
+
if self.is_connected:
|
|
1045
|
+
await self._send_subscribe(channel)
|
|
1046
|
+
|
|
1047
|
+
async def unsubscribe(self, *channels: str) -> None:
|
|
1048
|
+
"""Unsubscribe from one or more channels.
|
|
1049
|
+
|
|
1050
|
+
Args:
|
|
1051
|
+
channels: Channel names to unsubscribe from.
|
|
1052
|
+
|
|
1053
|
+
Examples:
|
|
1054
|
+
>>> async def main(): # doctest: +SKIP
|
|
1055
|
+
... async with WebSocketManager("wss://stream.binance.com/ws") as ws:
|
|
1056
|
+
... await ws.unsubscribe("btcusdt@trade")
|
|
1057
|
+
"""
|
|
1058
|
+
for channel in channels:
|
|
1059
|
+
self._subscriptions.discard(channel)
|
|
1060
|
+
if self.is_connected and self._ws is not None:
|
|
1061
|
+
message = json.dumps({"type": "unsubscribe", "channel": channel})
|
|
1062
|
+
await self._ws.send(message)
|
|
1063
|
+
|
|
1064
|
+
# =========================================================================
|
|
1065
|
+
# WAIT METHODS
|
|
1066
|
+
# =========================================================================
|
|
1067
|
+
|
|
1068
|
+
async def wait_connected(self, timeout: float | None = None) -> bool:
|
|
1069
|
+
"""Wait until connected.
|
|
1070
|
+
|
|
1071
|
+
Args:
|
|
1072
|
+
timeout: Maximum time to wait (seconds).
|
|
1073
|
+
|
|
1074
|
+
Returns:
|
|
1075
|
+
True if connected, False if timed out.
|
|
1076
|
+
"""
|
|
1077
|
+
try:
|
|
1078
|
+
if timeout is not None:
|
|
1079
|
+
await asyncio.wait_for(self._connected_event.wait(), timeout=timeout)
|
|
1080
|
+
else:
|
|
1081
|
+
await self._connected_event.wait()
|
|
1082
|
+
return True
|
|
1083
|
+
except asyncio.TimeoutError:
|
|
1084
|
+
return False
|
|
1085
|
+
|
|
1086
|
+
async def wait_disconnected(self, timeout: float | None = None) -> bool:
|
|
1087
|
+
"""Wait until disconnected.
|
|
1088
|
+
|
|
1089
|
+
Args:
|
|
1090
|
+
timeout: Maximum time to wait (seconds).
|
|
1091
|
+
|
|
1092
|
+
Returns:
|
|
1093
|
+
True if disconnected, False if timed out.
|
|
1094
|
+
"""
|
|
1095
|
+
try:
|
|
1096
|
+
if timeout is not None:
|
|
1097
|
+
await asyncio.wait_for(self._disconnected_event.wait(), timeout=timeout)
|
|
1098
|
+
else:
|
|
1099
|
+
await self._disconnected_event.wait()
|
|
1100
|
+
return True
|
|
1101
|
+
except asyncio.TimeoutError:
|
|
1102
|
+
return False
|