bynn-decisionnode 0.3.1__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.
@@ -0,0 +1,711 @@
1
+ """Sessions: one long-lived question set answered for every frame on a WebSocket.
2
+
3
+ ``open_session`` sends ``POST /v1/sessions``; :meth:`Session.connect` opens the stream
4
+ (the key travels in the handshake's ``Authorization`` header, never in the URL);
5
+ :meth:`Session.send` sends one frame; :meth:`Session.replies` yields what comes back.
6
+ Replies and frame errors are values, never exceptions. The stream ends with a
7
+ :class:`SessionClosed`, which says by ``reopen`` whether opening a new session with the
8
+ same body is the right next step.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import inspect
15
+ import json
16
+ import logging
17
+ import random
18
+ import time
19
+ from typing import (
20
+ TYPE_CHECKING,
21
+ Any,
22
+ AsyncIterator,
23
+ Awaitable,
24
+ Callable,
25
+ Dict,
26
+ Iterable,
27
+ Iterator,
28
+ List,
29
+ Mapping,
30
+ Optional,
31
+ Union,
32
+ )
33
+ from urllib.parse import quote, urlsplit
34
+
35
+ from . import _base
36
+ from ._errors import ConnectionError, DecisionNodeError, NotFoundError, TimeoutError
37
+ from ._images import Image
38
+ from ._types import AnyAnswer, Safety, Usage, parse_answer, parse_cost
39
+
40
+ if TYPE_CHECKING:
41
+ from ._client import AsyncDecisionNode, DecisionNode
42
+
43
+ __all__ = [
44
+ "Session",
45
+ "AsyncSession",
46
+ "Reply",
47
+ "FrameError",
48
+ "SessionClosed",
49
+ "SessionEvent",
50
+ "REOPEN_CODES",
51
+ ]
52
+
53
+ logger = logging.getLogger("decisionnode")
54
+
55
+ # Close codes after which opening a new session with the same body is the right step.
56
+ REOPEN_CODES = frozenset({1001, 1006, 1011, 4404, 4408, 4409, 4410})
57
+ # The server's socket cap: the session is kept and the client connects to it again.
58
+ TRY_AGAIN = 1013
59
+ # Codes reopened without a wait: the idle close (on the next reading) and the TTL close.
60
+ REOPEN_AT_ONCE = frozenset({4408, 4410})
61
+ # The largest message the server reads is 16 MiB; replies are far smaller.
62
+ MAX_MESSAGE = 16 * 1024 * 1024
63
+ RECONNECT_BASE = 1.0
64
+ RECONNECT_CAP = 30.0
65
+ LOOPBACK_HOSTS = frozenset({"localhost", "127.0.0.1", "::1"})
66
+
67
+
68
+ class SessionClosed(DecisionNodeError):
69
+ """The stream closed. Also raised by :meth:`Session.connect` for a refused handshake.
70
+
71
+ Attributes:
72
+ code: The WebSocket close code; ``1006`` when the connection was lost without one.
73
+ reason: The close reason as sent, for logs only.
74
+ reopen: True when a new session with the same body is the right next step
75
+ (``1001``, ``1006``, ``1011``, ``4404``, ``4408``, ``4409``, ``4410``). False for
76
+ every other code, ``1013`` included: there the session is kept and the
77
+ client connects to it again after a wait.
78
+ """
79
+
80
+ def __init__(self, code: int, reason: str = "") -> None:
81
+ super().__init__("session closed with %d%s" % (code, (": " + reason) if reason else ""), reason=reason)
82
+ self.code = code
83
+ self.reopen = code in REOPEN_CODES
84
+
85
+ def __repr__(self) -> str:
86
+ return "SessionClosed(code=%d, reopen=%s)" % (self.code, self.reopen)
87
+
88
+
89
+ def _safety(value: Any) -> Optional[Safety]:
90
+ if not isinstance(value, Mapping) or not isinstance(value.get("checked"), bool):
91
+ return None
92
+ if not value["checked"]:
93
+ reason = value.get("reason")
94
+ return Safety(checked=False, reason=reason if isinstance(reason, str) else None)
95
+ categories = {
96
+ k: (v if isinstance(v, (int, float)) and not isinstance(v, bool) else None)
97
+ for k, v in value.items()
98
+ if k not in ("checked", "flagged", "blocked", "reason")
99
+ }
100
+ flagged = value.get("flagged")
101
+ return Safety(
102
+ checked=True,
103
+ harm=categories.get("harm"),
104
+ self_harm=categories.get("self_harm"),
105
+ flagged=[str(f) for f in flagged] if isinstance(flagged, list) else [],
106
+ categories=categories,
107
+ )
108
+
109
+
110
+ def _usage(value: Any) -> Optional[Usage]:
111
+ if not isinstance(value, Mapping):
112
+ return None
113
+ return Usage(
114
+ input_tokens=value.get("input_tokens"),
115
+ output_tokens=value.get("output_tokens"),
116
+ cost=parse_cost(value.get("cost")),
117
+ )
118
+
119
+
120
+ class Reply:
121
+ """The answers to one frame.
122
+
123
+ Attributes:
124
+ seq: The ``seq`` of the frame answered.
125
+ answers: The answers by question id, typed as in a :class:`Decision`.
126
+ dropped: The ``seq`` of each frame superseded by a newer one since the previous
127
+ reply (latest wins), or ``None``.
128
+ usage: What the frame billed.
129
+ latency_ms: The server's time for the frame.
130
+ safety: The safety check on the frame, or ``None``.
131
+ raw: The message as received.
132
+ """
133
+
134
+ __slots__ = ("seq", "answers", "dropped", "usage", "latency_ms", "safety", "raw")
135
+
136
+ def __init__(self, raw: Dict[str, Any]) -> None:
137
+ self.raw = raw
138
+ self.seq: Optional[int] = raw.get("seq")
139
+ answers = raw.get("answers")
140
+ self.answers: Dict[str, AnyAnswer] = (
141
+ {str(k): parse_answer(v) for k, v in answers.items()} if isinstance(answers, Mapping) else {}
142
+ )
143
+ dropped = raw.get("dropped")
144
+ self.dropped: Optional[List[int]] = list(dropped) if isinstance(dropped, list) else None
145
+ self.usage = _usage(raw.get("usage"))
146
+ self.latency_ms: Optional[Union[int, float]] = raw.get("latency_ms")
147
+ self.safety = _safety(raw.get("safety"))
148
+
149
+ def __getitem__(self, question_id: str) -> Any:
150
+ return self.answers[question_id]
151
+
152
+ def __repr__(self) -> str:
153
+ return "Reply(seq=%r, answers=%r, dropped=%r)" % (self.seq, self.answers, self.dropped)
154
+
155
+
156
+ class FrameError:
157
+ """A frame the server could not answer, as a value. The session stays open.
158
+
159
+ Attributes:
160
+ seq: The frame's ``seq``, or ``None`` when the message could not be read as a frame.
161
+ error_type: ``api_usage_error``, ``rate_limit_error``, ``overloaded_error``,
162
+ ``api_error``, ``refusal_error``, or an upload error such as ``upload_not_found``.
163
+ message: The server's message.
164
+ category: For ``refusal_error``: ``harm`` or ``self_harm``.
165
+ retry_after_ms: For ``rate_limit_error`` and ``overloaded_error``: when to send again.
166
+ usage: For ``refusal_error``: what the refused frame billed.
167
+ safety: For ``refusal_error``: the safety check.
168
+ raw: The message as received.
169
+ """
170
+
171
+ __slots__ = ("seq", "error_type", "message", "category", "retry_after_ms", "usage", "safety", "raw")
172
+
173
+ def __init__(self, raw: Dict[str, Any]) -> None:
174
+ self.raw = raw
175
+ found = raw.get("error")
176
+ err: Mapping[str, Any] = found if isinstance(found, Mapping) else {}
177
+ self.seq: Optional[int] = raw.get("seq")
178
+ self.error_type: Optional[str] = err.get("error_type")
179
+ message = err.get("message")
180
+ self.message: str = message if isinstance(message, str) else ""
181
+ self.category: Optional[str] = err.get("category")
182
+ self.retry_after_ms: Optional[int] = raw.get("retry_after_ms")
183
+ self.usage = _usage(raw.get("usage"))
184
+ self.safety = _safety(raw.get("safety"))
185
+
186
+ def __repr__(self) -> str:
187
+ return "FrameError(seq=%r, error_type=%r, message=%r)" % (self.seq, self.error_type, self.message)
188
+
189
+
190
+ SessionEvent = Union[Reply, FrameError, SessionClosed]
191
+ """What :meth:`Session.replies` yields: replies and frame errors, then the close."""
192
+
193
+
194
+ def parse_event(text: Union[str, bytes]) -> Optional[Union[Reply, FrameError]]:
195
+ try:
196
+ raw = json.loads(text)
197
+ except ValueError:
198
+ logger.debug("session: a message that is not JSON was skipped")
199
+ return None
200
+ if not isinstance(raw, dict):
201
+ return None
202
+ return FrameError(raw) if "error" in raw else Reply(raw)
203
+
204
+
205
+ def check_stream_url(url: Any) -> str:
206
+ """``wss`` only; ``ws`` is accepted for a loopback host alone, so the key never
207
+ travels unencrypted."""
208
+ if not isinstance(url, str):
209
+ raise DecisionNodeError("the session has no stream_url")
210
+ parts = urlsplit(url)
211
+ scheme = parts.scheme.lower()
212
+ host = (parts.hostname or "").lower()
213
+ if scheme == "wss" and host:
214
+ return url
215
+ if scheme == "ws" and host in LOOPBACK_HOSTS:
216
+ return url
217
+ raise DecisionNodeError("the session's stream_url %r is not wss; the key is never sent over it" % url)
218
+
219
+
220
+ def frame_message(seq: int, frame: Any, image: Any, questions: Any, reset: Any) -> Dict[str, Any]:
221
+ msg: Dict[str, Any] = {"seq": seq}
222
+ if frame is not None:
223
+ msg["frame"] = frame
224
+ if image is not None:
225
+ msg["image"] = image
226
+ if questions is not None:
227
+ if isinstance(questions, (str, bytes)) or not isinstance(questions, Iterable):
228
+ raise ValueError("questions must be a list of the session's question ids")
229
+ msg["questions"] = list(questions)
230
+ if reset is not None:
231
+ msg["reset"] = reset
232
+ return msg
233
+
234
+
235
+ def reconnect_wait(failures: int) -> float:
236
+ """Capped backoff from 1 s to 30 s with jitter."""
237
+ return min(RECONNECT_CAP, RECONNECT_BASE * float(2**failures)) * (0.5 + random.random() * 0.5)
238
+
239
+
240
+ def _closed_from(exc: BaseException) -> SessionClosed:
241
+ rcvd = getattr(exc, "rcvd", None)
242
+ if rcvd is None:
243
+ return SessionClosed(1006, "")
244
+ return SessionClosed(int(rcvd.code), str(rcvd.reason or ""))
245
+
246
+
247
+ def _handshake_error(exc: BaseException, timeout: float) -> DecisionNodeError:
248
+ response = getattr(exc, "response", None)
249
+ if response is not None and getattr(response, "status_code", None) is not None:
250
+ headers = {k.lower(): v for k, v in response.headers.raw_items()}
251
+ body = response.body.decode("utf-8", errors="replace") if response.body else ""
252
+ return _base.make_error(int(response.status_code), headers, body)
253
+ if isinstance(exc, (asyncio.TimeoutError, TimeoutError)) or type(exc).__name__ == "TimeoutError":
254
+ return TimeoutError("the stream did not open within %gs" % timeout)
255
+ return ConnectionError("the stream could not be opened: %s" % (exc or type(exc).__name__))
256
+
257
+
258
+ class _SessionInfo:
259
+ """Every field of the open response."""
260
+
261
+ _client: Any
262
+
263
+ def __init__(self, raw: Dict[str, Any], headers: Mapping[str, str]) -> None:
264
+ self.raw = raw
265
+ self.session_id: str = raw.get("session_id", "")
266
+ self.model: Optional[str] = raw.get("model")
267
+ self.stream_url: Optional[str] = raw.get("stream_url")
268
+ self.window: Optional[int] = raw.get("window")
269
+ self.ttl_seconds: Optional[int] = raw.get("ttl_seconds")
270
+ self.expires_at: Optional[str] = raw.get("expires_at")
271
+ self.limits: Dict[str, Any] = dict(raw.get("limits") or {})
272
+ self.questions: List[str] = list(raw.get("questions") or [])
273
+ self.prefix_tokens: Optional[int] = raw.get("prefix_tokens")
274
+ self.usage = _usage(raw.get("usage")) or Usage()
275
+ answers = raw.get("answers")
276
+ self.answers: Dict[str, AnyAnswer] = (
277
+ {str(k): parse_answer(v) for k, v in answers.items()} if isinstance(answers, Mapping) else {}
278
+ )
279
+ self.safety = _safety(raw.get("safety"))
280
+ self.request_id: Optional[str] = headers.get("x-request-id")
281
+ self._seq = 0
282
+ self._closed: Optional[SessionClosed] = None
283
+
284
+ def _next_seq(self, seq: Optional[int]) -> int:
285
+ if seq is None:
286
+ self._seq += 1
287
+ return self._seq
288
+ if isinstance(seq, bool) or not isinstance(seq, int) or seq < 0:
289
+ raise ValueError("seq must be a whole number, 0 or more, got %r" % (seq,))
290
+ return seq
291
+
292
+ def check_frame(
293
+ self,
294
+ frame: Any = None,
295
+ image: Union[Image, Mapping[str, Any], None] = None,
296
+ questions: Optional[Iterable[str]] = None,
297
+ reset: Optional[bool] = None,
298
+ seq: Optional[int] = None,
299
+ ) -> None:
300
+ """Run every local check :meth:`send` would run on this frame and raise the same
301
+ error; send and upload nothing, and use no ``seq``. Returns ``None`` when the frame
302
+ would be sent. Needs no connection, and is not a coroutine on either client."""
303
+ self._client._check_frame(self, frame, image, questions, reset, seq)
304
+
305
+ def _end_path(self) -> str:
306
+ return "%s/%s" % (_base.SESSIONS_PATH, quote(self.session_id, safe=""))
307
+
308
+ def __repr__(self) -> str:
309
+ return "%s(session_id=%r, model=%r)" % (type(self).__name__, self.session_id, self.model)
310
+
311
+
312
+ class Session(_SessionInfo):
313
+ """An open session of the blocking client. Made by :meth:`DecisionNode.open_session`.
314
+
315
+ Every field of the open response is an attribute (``session_id``, ``model``,
316
+ ``stream_url``, ``window``, ``ttl_seconds``, ``expires_at``, ``limits``,
317
+ ``questions``, ``prefix_tokens``, ``usage``, ``answers``, ``safety``), and ``raw``
318
+ holds it as received. Use it as a context manager to end it on exit.
319
+ """
320
+
321
+ def __init__(self, client: DecisionNode, raw: Dict[str, Any], headers: Mapping[str, str]) -> None:
322
+ super().__init__(raw, headers)
323
+ self._client = client
324
+ self._ws: Any = None
325
+
326
+ def connect(self) -> Session:
327
+ """Open the stream. A handshake the server refuses with an HTTP status raises the
328
+ error class of that status; a close at connect arrives as the first event."""
329
+ from websockets.exceptions import InvalidHandshake
330
+ from websockets.sync.client import connect
331
+
332
+ settings = self._client._settings
333
+ url = check_stream_url(self.stream_url)
334
+ try:
335
+ self._ws = connect(
336
+ url,
337
+ additional_headers=settings.stream_headers(),
338
+ user_agent_header=_base.USER_AGENT,
339
+ compression=None,
340
+ open_timeout=settings.timeout,
341
+ max_size=MAX_MESSAGE,
342
+ **self._client._stream_options(url),
343
+ )
344
+ except (InvalidHandshake, OSError, TimeoutError) as exc:
345
+ raise _handshake_error(exc, settings.timeout) from exc
346
+ self._closed = None
347
+ return self
348
+
349
+ def send(
350
+ self,
351
+ frame: Any = None,
352
+ image: Union[Image, Mapping[str, Any], None] = None,
353
+ questions: Optional[Iterable[str]] = None,
354
+ reset: Optional[bool] = None,
355
+ seq: Optional[int] = None,
356
+ ) -> int:
357
+ """Send one frame and return its ``seq``. Never waits for the reply.
358
+
359
+ ``seq`` counts from 1 when not given. An ``image`` is checked first; a small one
360
+ goes inline, a large or already uploaded one by its ``upload_id``.
361
+ """
362
+ if self._ws is None:
363
+ raise ValueError("connect() the session before sending")
364
+ # Every fixed rule of the frame first: a refused frame writes nothing and takes no seq.
365
+ questions = self._client._check_frame(self, frame, image, questions, reset, seq)
366
+ wire = self._client._frame_image(image) if image is not None else None
367
+ number = self._next_seq(seq)
368
+ msg = frame_message(number, frame, wire, questions, reset)
369
+ try:
370
+ self._ws.send(_base.encode(msg).decode("utf-8"))
371
+ except Exception as exc: # noqa: BLE001 - a closed socket shows as the next event
372
+ if type(exc).__name__.startswith("ConnectionClosed"):
373
+ self._closed = self._closed or _closed_from(exc)
374
+ else:
375
+ raise
376
+ return number
377
+
378
+ def next_event(self, timeout: Optional[float] = None) -> Optional[SessionEvent]:
379
+ """The next :class:`Reply`, :class:`FrameError` or :class:`SessionClosed` (the last
380
+ event, returned again on every later call); ``None`` when nothing arrived
381
+ within ``timeout`` seconds."""
382
+ if self._closed is not None:
383
+ return self._closed
384
+ if self._ws is None:
385
+ raise ValueError("connect() the session before reading")
386
+ while True:
387
+ try:
388
+ text = self._ws.recv(timeout=timeout)
389
+ except TimeoutError:
390
+ return None
391
+ except Exception as exc: # noqa: BLE001
392
+ if type(exc).__name__.startswith("ConnectionClosed"):
393
+ self._closed = _closed_from(exc)
394
+ return self._closed
395
+ raise
396
+ event = parse_event(text)
397
+ if event is not None:
398
+ return event
399
+
400
+ def replies(self, timeout: Optional[float] = None) -> Iterator[SessionEvent]:
401
+ """Yield replies and frame errors as they come, then the :class:`SessionClosed`.
402
+
403
+ With ``timeout``, stop early when nothing arrives for that many seconds.
404
+ """
405
+ while True:
406
+ event = self.next_event(timeout)
407
+ if event is None:
408
+ return
409
+ yield event
410
+ if isinstance(event, SessionClosed):
411
+ return
412
+
413
+ def end(self) -> None:
414
+ """End the session (``DELETE``). A session that has already ended counts as ended."""
415
+ try:
416
+ self._client._send("DELETE", self._end_path(), None)
417
+ except NotFoundError:
418
+ pass
419
+
420
+ def close(self) -> None:
421
+ """Close the stream with ``1000``. The session ends with it."""
422
+ if self._ws is not None:
423
+ try:
424
+ self._ws.close()
425
+ except Exception: # noqa: BLE001 - closing a broken socket
426
+ pass
427
+
428
+ def __enter__(self) -> Session:
429
+ return self
430
+
431
+ def __exit__(self, *exc: Any) -> None:
432
+ try:
433
+ self.end()
434
+ finally:
435
+ self.close()
436
+
437
+
438
+ class AsyncSession(_SessionInfo):
439
+ """An open session of the asyncio client. Same fields and methods as :class:`Session`;
440
+ every method is a coroutine, and :meth:`replies` is an async iterator."""
441
+
442
+ def __init__(self, client: AsyncDecisionNode, raw: Dict[str, Any], headers: Mapping[str, str]) -> None:
443
+ super().__init__(raw, headers)
444
+ self._client = client
445
+ self._ws: Any = None
446
+
447
+ async def connect(self) -> AsyncSession:
448
+ """Open the stream. See :meth:`Session.connect`."""
449
+ from websockets.asyncio.client import connect
450
+ from websockets.exceptions import InvalidHandshake
451
+
452
+ settings = self._client._settings
453
+ url = check_stream_url(self.stream_url)
454
+ try:
455
+ self._ws = await connect(
456
+ url,
457
+ additional_headers=settings.stream_headers(),
458
+ user_agent_header=_base.USER_AGENT,
459
+ compression=None,
460
+ open_timeout=settings.timeout,
461
+ max_size=MAX_MESSAGE,
462
+ **self._client._stream_options(url),
463
+ )
464
+ except (InvalidHandshake, OSError, asyncio.TimeoutError) as exc:
465
+ raise _handshake_error(exc, settings.timeout) from exc
466
+ self._closed = None
467
+ return self
468
+
469
+ async def send(
470
+ self,
471
+ frame: Any = None,
472
+ image: Union[Image, Mapping[str, Any], None] = None,
473
+ questions: Optional[Iterable[str]] = None,
474
+ reset: Optional[bool] = None,
475
+ seq: Optional[int] = None,
476
+ ) -> int:
477
+ """Send one frame and return its ``seq``. See :meth:`Session.send`."""
478
+ if self._ws is None:
479
+ raise ValueError("connect() the session before sending")
480
+ questions = self._client._check_frame(self, frame, image, questions, reset, seq)
481
+ wire = await self._client._frame_image(image) if image is not None else None
482
+ number = self._next_seq(seq)
483
+ msg = frame_message(number, frame, wire, questions, reset)
484
+ try:
485
+ await self._ws.send(_base.encode(msg).decode("utf-8"))
486
+ except Exception as exc: # noqa: BLE001
487
+ if type(exc).__name__.startswith("ConnectionClosed"):
488
+ self._closed = self._closed or _closed_from(exc)
489
+ else:
490
+ raise
491
+ return number
492
+
493
+ async def next_event(self, timeout: Optional[float] = None) -> Optional[SessionEvent]:
494
+ """The next event, or ``None`` after ``timeout`` seconds. See :meth:`Session.next_event`."""
495
+ if self._closed is not None:
496
+ return self._closed
497
+ if self._ws is None:
498
+ raise ValueError("connect() the session before reading")
499
+ while True:
500
+ try:
501
+ if timeout is None:
502
+ text = await self._ws.recv()
503
+ else:
504
+ # recv() is safe to cancel; a buffered message needs one turn of the loop.
505
+ text = await asyncio.wait_for(self._ws.recv(), max(timeout, 0.001))
506
+ except asyncio.TimeoutError:
507
+ return None
508
+ except Exception as exc: # noqa: BLE001
509
+ if type(exc).__name__.startswith("ConnectionClosed"):
510
+ self._closed = _closed_from(exc)
511
+ return self._closed
512
+ raise
513
+ event = parse_event(text)
514
+ if event is not None:
515
+ return event
516
+
517
+ async def replies(self, timeout: Optional[float] = None) -> AsyncIterator[SessionEvent]:
518
+ """Yield replies and frame errors as they come, then the :class:`SessionClosed`."""
519
+ while True:
520
+ event = await self.next_event(timeout)
521
+ if event is None:
522
+ return
523
+ yield event
524
+ if isinstance(event, SessionClosed):
525
+ return
526
+
527
+ async def end(self) -> None:
528
+ """End the session (``DELETE``). A session that has already ended counts as ended."""
529
+ try:
530
+ await self._client._send("DELETE", self._end_path(), None)
531
+ except NotFoundError:
532
+ pass
533
+
534
+ async def close(self) -> None:
535
+ """Close the stream with ``1000``."""
536
+ if self._ws is not None:
537
+ try:
538
+ await self._ws.close()
539
+ except Exception: # noqa: BLE001
540
+ pass
541
+
542
+ async def __aenter__(self) -> AsyncSession:
543
+ return self
544
+
545
+ async def __aexit__(self, *exc: Any) -> None:
546
+ try:
547
+ await self.end()
548
+ finally:
549
+ await self.close()
550
+
551
+
552
+ # --------------------------------------------------------------------------- supervise
553
+
554
+
555
+ class _Supervisor:
556
+ """The bookkeeping shared by both forms of ``supervise``."""
557
+
558
+ def __init__(self) -> None:
559
+ self.owed = 0 # frames sent and not yet answered, failed or dropped
560
+ self.failures = 0 # reopens in a row without an answered frame
561
+ self.answered = 0 # replies on the current session
562
+ self.last: Optional[SessionClosed] = None # the last close seen, returned at the end
563
+ self.cause: Optional[SessionClosed] = None # the close the next open answers
564
+
565
+ def seen(self, event: Union[Reply, FrameError]) -> None:
566
+ dropped = event.dropped if isinstance(event, Reply) else None
567
+ self.owed = max(0, self.owed - 1 - len(dropped or ()))
568
+ if isinstance(event, Reply):
569
+ self.answered += 1
570
+
571
+ def closed(self, event: SessionClosed) -> None:
572
+ self.last = event
573
+ self.cause = event
574
+ self.owed = 0
575
+
576
+ def stop(self) -> bool:
577
+ """Stop on every close code that is neither a reopen code nor 1013."""
578
+ cause = self.cause
579
+ return cause is not None and not cause.reopen and cause.code != TRY_AGAIN
580
+
581
+ def wait(self) -> Optional[float]:
582
+ """The wait before opening (or connecting) again, or ``None`` for at once."""
583
+ cause = self.cause
584
+ if cause is None or cause.code in REOPEN_AT_ONCE:
585
+ return None
586
+ if self.answered:
587
+ self.failures = 0
588
+ wait = reconnect_wait(self.failures)
589
+ self.failures += 1
590
+ return wait
591
+
592
+
593
+ def supervise(
594
+ client: DecisionNode,
595
+ body: Mapping[str, Any],
596
+ feed: Iterable[Mapping[str, Any]],
597
+ on_reply: Callable[[Union[Reply, FrameError]], Any],
598
+ drain_timeout: float,
599
+ ) -> Optional[SessionClosed]:
600
+ sup = _Supervisor()
601
+ session: Optional[Session] = None
602
+ kept: Optional[Session] = None # closed with 1013: connect to it again
603
+
604
+ def drain(block: float) -> None:
605
+ nonlocal session, kept
606
+ deadline = time.monotonic() + block
607
+ while session is not None:
608
+ if block > 0 and sup.owed == 0:
609
+ return
610
+ remaining = max(0.0, deadline - time.monotonic())
611
+ event = session.next_event(timeout=remaining)
612
+ if event is None:
613
+ return
614
+ if isinstance(event, SessionClosed):
615
+ sup.closed(event)
616
+ session.close()
617
+ kept = session if event.code == TRY_AGAIN else None
618
+ session = None
619
+ return
620
+ sup.seen(event)
621
+ on_reply(event)
622
+
623
+ for reading in feed:
624
+ drain(0.0)
625
+ if sup.stop():
626
+ return sup.last
627
+ if session is None:
628
+ wait = sup.wait()
629
+ if wait is not None:
630
+ client._sleep(wait)
631
+ if kept is not None:
632
+ session, kept = kept, None
633
+ else:
634
+ session = client.open_session(**dict(body))
635
+ sup.answered = 0
636
+ session.connect()
637
+ sup.cause = None
638
+ session.send(**dict(reading))
639
+ sup.owed += 1
640
+ drain(drain_timeout)
641
+ if session is not None:
642
+ session.end()
643
+ session.close()
644
+ return sup.last
645
+
646
+
647
+ async def asupervise(
648
+ client: AsyncDecisionNode,
649
+ body: Mapping[str, Any],
650
+ feed: Union[Iterable[Mapping[str, Any]], AsyncIterator[Mapping[str, Any]]],
651
+ on_reply: Callable[[Union[Reply, FrameError]], Union[Any, Awaitable[Any]]],
652
+ drain_timeout: float,
653
+ ) -> Optional[SessionClosed]:
654
+ sup = _Supervisor()
655
+ session: Optional[AsyncSession] = None
656
+ kept: Optional[AsyncSession] = None
657
+
658
+ async def deliver(event: Union[Reply, FrameError]) -> None:
659
+ result = on_reply(event)
660
+ if inspect.isawaitable(result):
661
+ await result
662
+
663
+ async def drain(block: float) -> None:
664
+ nonlocal session, kept
665
+ deadline = time.monotonic() + block
666
+ while session is not None:
667
+ if block > 0 and sup.owed == 0:
668
+ return
669
+ remaining = max(0.0, deadline - time.monotonic())
670
+ event = await session.next_event(timeout=remaining)
671
+ if event is None:
672
+ return
673
+ if isinstance(event, SessionClosed):
674
+ sup.closed(event)
675
+ await session.close()
676
+ kept = session if event.code == TRY_AGAIN else None
677
+ session = None
678
+ return
679
+ sup.seen(event)
680
+ await deliver(event)
681
+
682
+ async def readings() -> AsyncIterator[Mapping[str, Any]]:
683
+ if hasattr(feed, "__aiter__"):
684
+ async for item in feed:
685
+ yield item
686
+ else:
687
+ for item in feed:
688
+ yield item
689
+
690
+ async for reading in readings():
691
+ await drain(0.0)
692
+ if sup.stop():
693
+ return sup.last
694
+ if session is None:
695
+ wait = sup.wait()
696
+ if wait is not None:
697
+ await client._sleep(wait)
698
+ if kept is not None:
699
+ session, kept = kept, None
700
+ else:
701
+ session = await client.open_session(**dict(body))
702
+ sup.answered = 0
703
+ await session.connect()
704
+ sup.cause = None
705
+ await session.send(**dict(reading))
706
+ sup.owed += 1
707
+ await drain(drain_timeout)
708
+ if session is not None:
709
+ await session.end()
710
+ await session.close()
711
+ return sup.last