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 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)