layr8 0.2.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.
- layr8/__init__.py +46 -0
- layr8/backoff.py +22 -0
- layr8/channel.py +454 -0
- layr8/client.py +685 -0
- layr8/config.py +59 -0
- layr8/credentials.py +58 -0
- layr8/errors.py +130 -0
- layr8/handler.py +71 -0
- layr8/message.py +196 -0
- layr8/presentations.py +14 -0
- layr8/rest.py +146 -0
- layr8/sentinel.py +23 -0
- layr8-0.2.0.dist-info/METADATA +14 -0
- layr8-0.2.0.dist-info/RECORD +15 -0
- layr8-0.2.0.dist-info/WHEEL +4 -0
layr8/__init__.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Layr8 DIDComm Agent SDK for Python."""
|
|
2
|
+
|
|
3
|
+
from .client import Client
|
|
4
|
+
from .config import Config
|
|
5
|
+
from .credentials import Credential, StoredCredential, VerifiedCredential
|
|
6
|
+
from .errors import (
|
|
7
|
+
AlreadyConnectedError,
|
|
8
|
+
ClientClosedError,
|
|
9
|
+
ErrorKind,
|
|
10
|
+
Layr8ConnectionError,
|
|
11
|
+
Layr8Error,
|
|
12
|
+
NotConnectedError,
|
|
13
|
+
ProblemReportError,
|
|
14
|
+
SDKError,
|
|
15
|
+
ServerRejectError,
|
|
16
|
+
log_errors,
|
|
17
|
+
)
|
|
18
|
+
from .message import Attachment, AttachmentData, Credential, Message, MessageContext
|
|
19
|
+
from .presentations import VerifiedPresentation
|
|
20
|
+
from .rest import RESTError
|
|
21
|
+
from .sentinel import PASS
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"Attachment",
|
|
25
|
+
"AttachmentData",
|
|
26
|
+
"Client",
|
|
27
|
+
"Config",
|
|
28
|
+
"Message",
|
|
29
|
+
"MessageContext",
|
|
30
|
+
"Credential",
|
|
31
|
+
"VerifiedCredential",
|
|
32
|
+
"StoredCredential",
|
|
33
|
+
"VerifiedPresentation",
|
|
34
|
+
"RESTError",
|
|
35
|
+
"Layr8Error",
|
|
36
|
+
"NotConnectedError",
|
|
37
|
+
"AlreadyConnectedError",
|
|
38
|
+
"ClientClosedError",
|
|
39
|
+
"ProblemReportError",
|
|
40
|
+
"ServerRejectError",
|
|
41
|
+
"Layr8ConnectionError",
|
|
42
|
+
"ErrorKind",
|
|
43
|
+
"SDKError",
|
|
44
|
+
"log_errors",
|
|
45
|
+
"PASS",
|
|
46
|
+
]
|
layr8/backoff.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Exponential backoff with a maximum delay."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class Backoff:
|
|
7
|
+
"""Exponential backoff with a maximum delay."""
|
|
8
|
+
|
|
9
|
+
__slots__ = ("_initial", "_max", "_current")
|
|
10
|
+
|
|
11
|
+
def __init__(self, initial: float, maximum: float) -> None:
|
|
12
|
+
self._initial = initial
|
|
13
|
+
self._max = maximum
|
|
14
|
+
self._current = initial
|
|
15
|
+
|
|
16
|
+
def next(self) -> float:
|
|
17
|
+
d = min(self._current, self._max)
|
|
18
|
+
self._current = min(self._current * 2, self._max)
|
|
19
|
+
return d
|
|
20
|
+
|
|
21
|
+
def reset(self) -> None:
|
|
22
|
+
self._current = self._initial
|
layr8/channel.py
ADDED
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""Phoenix Channel V2 transport over WebSocket."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import json
|
|
7
|
+
import socket
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
|
13
|
+
|
|
14
|
+
import websockets
|
|
15
|
+
import websockets.asyncio.client
|
|
16
|
+
|
|
17
|
+
from .backoff import Backoff
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ServerReply:
|
|
22
|
+
"""Parsed reply from the Phoenix server for a sent message."""
|
|
23
|
+
|
|
24
|
+
status: str = ""
|
|
25
|
+
reason: str = ""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _is_localhost(host: str) -> bool:
|
|
29
|
+
"""Return True if host is 'localhost' or a subdomain of it (RFC 6761)."""
|
|
30
|
+
return host == "localhost" or host.endswith(".localhost")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _create_localhost_socket(ws_url: str) -> socket.socket | None:
|
|
34
|
+
"""
|
|
35
|
+
Create a pre-connected TCP socket to 127.0.0.1 for *.localhost URLs (RFC 6761).
|
|
36
|
+
|
|
37
|
+
The websockets library does not reliably override the Host header via
|
|
38
|
+
additional_headers when connecting to 127.0.0.1. Instead, we pre-connect
|
|
39
|
+
a raw socket to loopback and pass it to websockets.connect(), which then
|
|
40
|
+
sends the correct Host header derived from the original URL.
|
|
41
|
+
|
|
42
|
+
Returns a connected socket if the host is *.localhost, or None otherwise.
|
|
43
|
+
"""
|
|
44
|
+
parsed = urlparse(ws_url)
|
|
45
|
+
hostname = parsed.hostname or ""
|
|
46
|
+
if not _is_localhost(hostname):
|
|
47
|
+
return None
|
|
48
|
+
port = parsed.port or (443 if parsed.scheme in ("wss", "https") else 80)
|
|
49
|
+
sock = socket.create_connection(("127.0.0.1", port), timeout=10)
|
|
50
|
+
return sock
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class PhoenixChannel:
|
|
54
|
+
"""
|
|
55
|
+
Phoenix Channel transport over WebSocket.
|
|
56
|
+
|
|
57
|
+
Implements the same wire protocol as the Go SDK's phoenixChannel:
|
|
58
|
+
V2 JSON array format [join_ref, ref, topic, event, payload].
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
ws_url: str,
|
|
64
|
+
api_key: str,
|
|
65
|
+
agent_did: str,
|
|
66
|
+
*,
|
|
67
|
+
on_message: Callable[[Any], None],
|
|
68
|
+
on_disconnect: Callable[[Exception], None] | None = None,
|
|
69
|
+
on_reconnect: Callable[[], None] | None = None,
|
|
70
|
+
) -> None:
|
|
71
|
+
self._ws_url = ws_url
|
|
72
|
+
self._api_key = api_key
|
|
73
|
+
self._topic = f"plugins:{agent_did}"
|
|
74
|
+
self._on_message = on_message
|
|
75
|
+
self._on_disconnect = on_disconnect
|
|
76
|
+
self._on_reconnect = on_reconnect
|
|
77
|
+
|
|
78
|
+
self._ws: websockets.asyncio.client.ClientConnection | None = None
|
|
79
|
+
self._ref_counter = 0
|
|
80
|
+
self._join_ref = ""
|
|
81
|
+
self._assigned_did = ""
|
|
82
|
+
self._closed = False
|
|
83
|
+
self._reconnecting: bool = False
|
|
84
|
+
self._protocols: list[str] = []
|
|
85
|
+
self._read_task: asyncio.Task[None] | None = None
|
|
86
|
+
self._heartbeat_task: asyncio.Task[None] | None = None
|
|
87
|
+
self._reconnect_task: asyncio.Task[None] | None = None
|
|
88
|
+
self._join_future: asyncio.Future[Any] | None = None
|
|
89
|
+
self._pending_refs: dict[str, asyncio.Future[ServerReply]] = {}
|
|
90
|
+
# Monotonic timestamp (time.monotonic()) of the most recently
|
|
91
|
+
# observed inbound Phoenix frame. Powers the application-layer
|
|
92
|
+
# watchdog in _heartbeat_loop — closes #5 by detecting "TCP healthy
|
|
93
|
+
# but Phoenix Channel GenServer hung" within ~75s.
|
|
94
|
+
#
|
|
95
|
+
# WS-level liveness (TCP / NAT / LB half-dead) is covered separately
|
|
96
|
+
# by the `websockets` library's built-in ping/pong mechanism, made
|
|
97
|
+
# explicit in the connect() call below — closes #4.
|
|
98
|
+
self._last_frame_at = time.monotonic()
|
|
99
|
+
self._reply_protocol: bool = False
|
|
100
|
+
|
|
101
|
+
async def connect(self, protocols: list[str]) -> None:
|
|
102
|
+
"""Establish WebSocket connection and join the Phoenix channel."""
|
|
103
|
+
self._protocols = protocols
|
|
104
|
+
await self._dial()
|
|
105
|
+
|
|
106
|
+
async def _dial(self) -> None:
|
|
107
|
+
"""Open the WebSocket and join the channel (used by connect and reconnect)."""
|
|
108
|
+
self._ref_counter = 0
|
|
109
|
+
|
|
110
|
+
parsed = urlparse(self._ws_url)
|
|
111
|
+
qs = parse_qs(parsed.query)
|
|
112
|
+
qs["api_key"] = [self._api_key]
|
|
113
|
+
qs["vsn"] = ["2.0.0"]
|
|
114
|
+
new_query = urlencode(qs, doseq=True)
|
|
115
|
+
full_url = urlunparse(parsed._replace(query=new_query))
|
|
116
|
+
|
|
117
|
+
# For *.localhost, pre-connect a raw socket to 127.0.0.1 so the
|
|
118
|
+
# websockets library sends the correct Host header from the URL.
|
|
119
|
+
sock = _create_localhost_socket(full_url)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
# Explicit WS-level ping/pong (issue #4 — defense-in-depth):
|
|
123
|
+
# `websockets` defaults to ping_interval=20, ping_timeout=20
|
|
124
|
+
# which already protects against TCP / NAT / LB half-dead. We
|
|
125
|
+
# set them explicitly so a future maintainer can't accidentally
|
|
126
|
+
# disable the layer (e.g. by passing ping_interval=None in a
|
|
127
|
+
# test scenario and shipping it). 30 / 20 leaves a 50s detection
|
|
128
|
+
# window — well under AWS NLB's 350s idle timeout — and matches
|
|
129
|
+
# the policy enforced in go-sdk.
|
|
130
|
+
self._ws = await websockets.asyncio.client.connect(
|
|
131
|
+
full_url,
|
|
132
|
+
sock=sock,
|
|
133
|
+
open_timeout=10,
|
|
134
|
+
ping_interval=30,
|
|
135
|
+
ping_timeout=20,
|
|
136
|
+
close_timeout=10,
|
|
137
|
+
)
|
|
138
|
+
except Exception as exc:
|
|
139
|
+
if sock:
|
|
140
|
+
sock.close()
|
|
141
|
+
raise _make_connection_error(self._ws_url, exc) from exc
|
|
142
|
+
|
|
143
|
+
# Reset the watchdog clock so the first heartbeat tick after
|
|
144
|
+
# (re)connect measures silence from "just now", not from before
|
|
145
|
+
# the disconnect.
|
|
146
|
+
self._last_frame_at = time.monotonic()
|
|
147
|
+
self._read_task = asyncio.create_task(self._read_loop())
|
|
148
|
+
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
|
149
|
+
|
|
150
|
+
await self._join(self._protocols)
|
|
151
|
+
|
|
152
|
+
async def _join(self, protocols: list[str]) -> None:
|
|
153
|
+
"""Send phx_join and wait for the reply."""
|
|
154
|
+
ref = self._next_ref()
|
|
155
|
+
self._join_ref = ref
|
|
156
|
+
|
|
157
|
+
join_payload: dict[str, Any] = {
|
|
158
|
+
"payload_types": protocols,
|
|
159
|
+
"reply_protocol": True,
|
|
160
|
+
"did_spec": {
|
|
161
|
+
"mode": "Create",
|
|
162
|
+
"storage": "ephemeral",
|
|
163
|
+
"type": "plugin",
|
|
164
|
+
"verificationMethods": [
|
|
165
|
+
{"purpose": "authentication"},
|
|
166
|
+
{"purpose": "assertionMethod"},
|
|
167
|
+
{"purpose": "keyAgreement"},
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
loop = asyncio.get_running_loop()
|
|
173
|
+
self._join_future = loop.create_future()
|
|
174
|
+
|
|
175
|
+
await self._write_msg(ref, ref, self._topic, "phx_join", join_payload)
|
|
176
|
+
|
|
177
|
+
try:
|
|
178
|
+
reply = await asyncio.wait_for(self._join_future, timeout=10)
|
|
179
|
+
except asyncio.TimeoutError:
|
|
180
|
+
raise _make_connection_error(self._ws_url, "join timed out")
|
|
181
|
+
|
|
182
|
+
status = reply.get("status")
|
|
183
|
+
if status != "ok":
|
|
184
|
+
response = reply.get("response", {})
|
|
185
|
+
reason = response.get("reason", "") if isinstance(response, dict) else ""
|
|
186
|
+
if reason:
|
|
187
|
+
raise _make_connection_error(self._ws_url, reason)
|
|
188
|
+
raise _make_connection_error(
|
|
189
|
+
self._ws_url, f"join rejected: {status}"
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
response = reply.get("response", {})
|
|
193
|
+
if isinstance(response, dict) and response.get("did"):
|
|
194
|
+
self._assigned_did = response["did"]
|
|
195
|
+
capabilities = response.get("capabilities", []) if isinstance(response, dict) else []
|
|
196
|
+
self._reply_protocol = "reply_protocol/1" in capabilities
|
|
197
|
+
|
|
198
|
+
async def send(self, event: str, payload: Any) -> ServerReply:
|
|
199
|
+
"""Send a Phoenix Channel event and wait for server reply."""
|
|
200
|
+
ref = self._next_ref()
|
|
201
|
+
loop = asyncio.get_running_loop()
|
|
202
|
+
future: asyncio.Future[ServerReply] = loop.create_future()
|
|
203
|
+
self._pending_refs[ref] = future
|
|
204
|
+
|
|
205
|
+
try:
|
|
206
|
+
await self._write_msg(None, ref, self._topic, event, payload)
|
|
207
|
+
return await asyncio.wait_for(future, timeout=15)
|
|
208
|
+
except asyncio.TimeoutError:
|
|
209
|
+
self._pending_refs.pop(ref, None)
|
|
210
|
+
raise
|
|
211
|
+
except Exception:
|
|
212
|
+
self._pending_refs.pop(ref, None)
|
|
213
|
+
raise
|
|
214
|
+
|
|
215
|
+
async def send_fire_and_forget(self, event: str, payload: Any) -> None:
|
|
216
|
+
"""Send a Phoenix Channel event without waiting for server reply."""
|
|
217
|
+
ref = self._next_ref()
|
|
218
|
+
await self._write_msg(None, ref, self._topic, event, payload)
|
|
219
|
+
|
|
220
|
+
async def send_ack(self, ids: list[str]) -> None:
|
|
221
|
+
"""Acknowledge message IDs to the cloud-node."""
|
|
222
|
+
await self.send_fire_and_forget("ack", {"ids": ids})
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def assigned_did(self) -> str:
|
|
226
|
+
return self._assigned_did
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def reply_protocol(self) -> bool:
|
|
230
|
+
return self._reply_protocol
|
|
231
|
+
|
|
232
|
+
async def close(self) -> None:
|
|
233
|
+
"""Send phx_leave and shut down gracefully."""
|
|
234
|
+
if self._closed:
|
|
235
|
+
return
|
|
236
|
+
self._closed = True
|
|
237
|
+
self._reconnecting = False
|
|
238
|
+
|
|
239
|
+
if self._reconnect_task:
|
|
240
|
+
self._reconnect_task.cancel()
|
|
241
|
+
try:
|
|
242
|
+
await self._reconnect_task
|
|
243
|
+
except asyncio.CancelledError:
|
|
244
|
+
pass
|
|
245
|
+
self._reconnect_task = None
|
|
246
|
+
|
|
247
|
+
if self._heartbeat_task:
|
|
248
|
+
self._heartbeat_task.cancel()
|
|
249
|
+
try:
|
|
250
|
+
await self._heartbeat_task
|
|
251
|
+
except asyncio.CancelledError:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
# Cancel all pending ref futures
|
|
255
|
+
for ref, future in list(self._pending_refs.items()):
|
|
256
|
+
if not future.done():
|
|
257
|
+
future.cancel()
|
|
258
|
+
self._pending_refs.clear()
|
|
259
|
+
|
|
260
|
+
if self._ws:
|
|
261
|
+
try:
|
|
262
|
+
ref = self._next_ref()
|
|
263
|
+
await self._write_msg(None, ref, self._topic, "phx_leave", {})
|
|
264
|
+
except Exception:
|
|
265
|
+
pass
|
|
266
|
+
await self._ws.close()
|
|
267
|
+
self._ws = None
|
|
268
|
+
|
|
269
|
+
if self._read_task:
|
|
270
|
+
self._read_task.cancel()
|
|
271
|
+
try:
|
|
272
|
+
await self._read_task
|
|
273
|
+
except asyncio.CancelledError:
|
|
274
|
+
pass
|
|
275
|
+
|
|
276
|
+
async def _read_loop(self) -> None:
|
|
277
|
+
"""Continuously read WebSocket messages and dispatch them."""
|
|
278
|
+
assert self._ws is not None
|
|
279
|
+
try:
|
|
280
|
+
async for raw in self._ws:
|
|
281
|
+
if self._closed:
|
|
282
|
+
return
|
|
283
|
+
# Any inbound frame proves the connection is alive
|
|
284
|
+
# end-to-end — update the watchdog clock before parsing.
|
|
285
|
+
# Heartbeat ack, message event, phx_reply, phx_error,
|
|
286
|
+
# all count.
|
|
287
|
+
self._last_frame_at = time.monotonic()
|
|
288
|
+
try:
|
|
289
|
+
arr = json.loads(raw)
|
|
290
|
+
if not isinstance(arr, list) or len(arr) != 5:
|
|
291
|
+
continue
|
|
292
|
+
join_ref, ref, topic, event, payload = arr
|
|
293
|
+
self._handle_inbound(join_ref, ref, topic, event, payload)
|
|
294
|
+
except (json.JSONDecodeError, ValueError):
|
|
295
|
+
continue
|
|
296
|
+
except websockets.exceptions.ConnectionClosed:
|
|
297
|
+
pass
|
|
298
|
+
except Exception as exc:
|
|
299
|
+
if not self._closed:
|
|
300
|
+
if self._on_disconnect:
|
|
301
|
+
self._on_disconnect(exc)
|
|
302
|
+
self._reject_pending_refs()
|
|
303
|
+
if not self._reconnecting:
|
|
304
|
+
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
|
|
305
|
+
return
|
|
306
|
+
|
|
307
|
+
# Iterator ended (clean close) or ConnectionClosed — trigger reconnect
|
|
308
|
+
if not self._closed:
|
|
309
|
+
if self._on_disconnect:
|
|
310
|
+
self._on_disconnect(Exception("WebSocket closed"))
|
|
311
|
+
self._reject_pending_refs()
|
|
312
|
+
if not self._reconnecting:
|
|
313
|
+
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
|
|
314
|
+
|
|
315
|
+
def _handle_inbound(
|
|
316
|
+
self,
|
|
317
|
+
join_ref: str | None,
|
|
318
|
+
ref: str | None,
|
|
319
|
+
topic: str,
|
|
320
|
+
event: str,
|
|
321
|
+
payload: Any,
|
|
322
|
+
) -> None:
|
|
323
|
+
"""Route inbound Phoenix messages to the appropriate handler."""
|
|
324
|
+
if event == "phx_reply":
|
|
325
|
+
# Join reply
|
|
326
|
+
if self._join_future and not self._join_future.done() and ref == self._join_ref:
|
|
327
|
+
self._join_future.set_result(payload)
|
|
328
|
+
return
|
|
329
|
+
|
|
330
|
+
# Message send reply (ref tracking)
|
|
331
|
+
if ref and ref in self._pending_refs:
|
|
332
|
+
future = self._pending_refs.pop(ref)
|
|
333
|
+
if not future.done():
|
|
334
|
+
status = ""
|
|
335
|
+
reason = ""
|
|
336
|
+
if isinstance(payload, dict):
|
|
337
|
+
status = payload.get("status", "")
|
|
338
|
+
response = payload.get("response", {})
|
|
339
|
+
if isinstance(response, dict):
|
|
340
|
+
reason = response.get("reason", "")
|
|
341
|
+
future.set_result(ServerReply(status=status, reason=reason))
|
|
342
|
+
elif event == "message":
|
|
343
|
+
self._on_message(payload)
|
|
344
|
+
elif event in ("phx_error", "phx_close"):
|
|
345
|
+
if self._on_disconnect:
|
|
346
|
+
self._on_disconnect(Exception(f"channel {event}"))
|
|
347
|
+
|
|
348
|
+
# Maximum time a connection may be silent before the Phoenix-layer
|
|
349
|
+
# watchdog treats it as hung. 2.5× the heartbeat interval — tolerates
|
|
350
|
+
# one missed reply, trips on two consecutive misses. Catches the case
|
|
351
|
+
# where TCP + cowboy pong are healthy but cloud-node's per-tenant
|
|
352
|
+
# Phoenix Channel GenServer has stopped processing (OOM cascade,
|
|
353
|
+
# deploy window, etc.) — closes #5.
|
|
354
|
+
_HEARTBEAT_INTERVAL_S = 30
|
|
355
|
+
_HEARTBEAT_MAX_SILENT_S = 75
|
|
356
|
+
|
|
357
|
+
async def _heartbeat_loop(self) -> None:
|
|
358
|
+
"""Send heartbeat every 30 seconds; close the WS if no inbound frame
|
|
359
|
+
has arrived within _HEARTBEAT_MAX_SILENT_S.
|
|
360
|
+
"""
|
|
361
|
+
try:
|
|
362
|
+
while not self._closed:
|
|
363
|
+
await asyncio.sleep(self._HEARTBEAT_INTERVAL_S)
|
|
364
|
+
if self._closed or not self._ws:
|
|
365
|
+
return
|
|
366
|
+
|
|
367
|
+
silent = time.monotonic() - self._last_frame_at
|
|
368
|
+
if silent > self._HEARTBEAT_MAX_SILENT_S:
|
|
369
|
+
# Application-layer hang: TCP + cowboy pong are still
|
|
370
|
+
# working (we wouldn't be here otherwise), but the
|
|
371
|
+
# Phoenix Channel GenServer is no longer responding to
|
|
372
|
+
# heartbeats. Close the socket; the read loop's
|
|
373
|
+
# ConnectionClosed handler will schedule reconnect.
|
|
374
|
+
try:
|
|
375
|
+
await self._ws.close()
|
|
376
|
+
except Exception:
|
|
377
|
+
# ignore — already in a bad state
|
|
378
|
+
pass
|
|
379
|
+
return
|
|
380
|
+
|
|
381
|
+
ref = self._next_ref()
|
|
382
|
+
await self._write_msg(None, ref, "phoenix", "heartbeat", {})
|
|
383
|
+
except asyncio.CancelledError:
|
|
384
|
+
pass
|
|
385
|
+
except Exception:
|
|
386
|
+
pass
|
|
387
|
+
|
|
388
|
+
def _next_ref(self) -> str:
|
|
389
|
+
self._ref_counter += 1
|
|
390
|
+
return str(self._ref_counter)
|
|
391
|
+
|
|
392
|
+
async def _write_msg(
|
|
393
|
+
self,
|
|
394
|
+
join_ref: str | None,
|
|
395
|
+
ref: str | None,
|
|
396
|
+
topic: str,
|
|
397
|
+
event: str,
|
|
398
|
+
payload: Any,
|
|
399
|
+
) -> None:
|
|
400
|
+
if not self._ws or self._reconnecting:
|
|
401
|
+
from .errors import NotConnectedError
|
|
402
|
+
|
|
403
|
+
raise NotConnectedError()
|
|
404
|
+
data = json.dumps([join_ref, ref, topic, event, payload])
|
|
405
|
+
await self._ws.send(data)
|
|
406
|
+
|
|
407
|
+
def _reject_pending_refs(self) -> None:
|
|
408
|
+
"""Cancel all pending ref futures."""
|
|
409
|
+
for ref, future in list(self._pending_refs.items()):
|
|
410
|
+
if not future.done():
|
|
411
|
+
future.cancel()
|
|
412
|
+
self._pending_refs.clear()
|
|
413
|
+
|
|
414
|
+
async def _reconnect_loop(self) -> None:
|
|
415
|
+
"""Attempt to reconnect with exponential backoff."""
|
|
416
|
+
if self._reconnecting:
|
|
417
|
+
return
|
|
418
|
+
self._reconnecting = True
|
|
419
|
+
|
|
420
|
+
# Clean up old connection
|
|
421
|
+
if self._ws:
|
|
422
|
+
try:
|
|
423
|
+
await self._ws.close()
|
|
424
|
+
except Exception:
|
|
425
|
+
pass
|
|
426
|
+
self._ws = None
|
|
427
|
+
if self._heartbeat_task:
|
|
428
|
+
self._heartbeat_task.cancel()
|
|
429
|
+
|
|
430
|
+
bo = Backoff(1.0, 30.0)
|
|
431
|
+
|
|
432
|
+
while not self._closed:
|
|
433
|
+
delay = bo.next()
|
|
434
|
+
await asyncio.sleep(delay)
|
|
435
|
+
if self._closed:
|
|
436
|
+
return
|
|
437
|
+
try:
|
|
438
|
+
self._reconnecting = False
|
|
439
|
+
await self._dial()
|
|
440
|
+
if self._on_reconnect:
|
|
441
|
+
self._on_reconnect()
|
|
442
|
+
return
|
|
443
|
+
except Exception:
|
|
444
|
+
self._reconnecting = True
|
|
445
|
+
continue
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _make_connection_error(
|
|
449
|
+
url: str, exc: Exception | str
|
|
450
|
+
) -> Exception:
|
|
451
|
+
from .errors import Layr8ConnectionError
|
|
452
|
+
|
|
453
|
+
reason = str(exc) if isinstance(exc, Exception) else exc
|
|
454
|
+
return Layr8ConnectionError(url=url, reason=reason)
|