Files
app/backend/fluksio/sdk/stream.py
T

89 lines
3.2 KiB
Python

"""The engine's event bus, read the way a browser reads it.
Polling answers "what is true now", so a run that starts and finishes between
two ticks was only ever a row in the history. This subscribes to the same
websocket the dashboard in a browser does — nothing new is served for it.
Blocking on purpose: `subscribe` is a thread's whole job, and the caller
brings the thread. Reconnecting is this module's, since a dashboard outlives
the engine it supervises.
"""
from __future__ import annotations
import json
import threading
from collections.abc import Callable, Collection
from typing import Any
from urllib.parse import urlsplit, urlunsplit
#: What a reconnect waits before trying again, growing to a minute — the shape
#: the engine's own supervision backs off with.
BACKOFF = (1.0, 5.0, 30.0, 60.0)
#: How long a read waits before checking `stop`, so an idle connection does
#: not delay closing the dashboard.
POLL_S = 1.0
def socket_url(url: str) -> str:
"""The websocket beside an engine's HTTP address.
The token is not in it: it goes as a header on the handshake, since a URL
ends up in access logs.
"""
parts = urlsplit(url)
scheme = "wss" if parts.scheme == "https" else "ws"
return urlunsplit((scheme, parts.netloc, "/api/v1/flows/ws", "", ""))
def subscribe(
url: str,
token: str,
on_event: Callable[[dict[str, Any]], None],
stop: threading.Event,
kinds: Collection[str] | None = None,
on_error: Callable[[Exception], None] | None = None,
) -> None:
"""Hand every published event to `on_event` until `stop` is set.
`kinds` keeps only the event types named, which is worth doing here rather
than in the caller: `message_value` is most of what the bus carries and a
screen drawing runs wants none of it.
A failure is reported once per outage. A dashboard is the thing that stops
and starts the engine, so a reconnect loop announcing itself every second
would bury the log pane it shares.
"""
from websockets.sync.client import connect
attempt = 0
while not stop.is_set():
try:
with connect(
socket_url(url),
additional_headers={"Authorization": f"Bearer {token}"},
open_timeout=POLL_S * 5,
) as socket:
attempt = 0
while not stop.is_set():
try:
frame = socket.recv(timeout=POLL_S)
except TimeoutError:
continue
payload = json.loads(frame)
events = (
payload.get("events") or []
if payload.get("type") == "batch"
else [payload]
)
for event in events:
if kinds is None or event.get("type") in kinds:
on_event(event)
return
except Exception as exc: # noqa: BLE001 — any transport failure retries
if attempt == 0 and on_error is not None:
on_error(exc)
stop.wait(BACKOFF[min(attempt, len(BACKOFF) - 1)])
attempt += 1