mgf-livepush 0.1.2__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.
- mgf/livepush/__init__.py +36 -0
- mgf/livepush/_broker.py +129 -0
- mgf/livepush/_errors.py +31 -0
- mgf/livepush/_slotcap.py +92 -0
- mgf/livepush/_sse.py +117 -0
- mgf/livepush/fastapi.py +50 -0
- mgf/livepush/py.typed +0 -0
- mgf_livepush-0.1.2.dist-info/METADATA +114 -0
- mgf_livepush-0.1.2.dist-info/RECORD +11 -0
- mgf_livepush-0.1.2.dist-info/WHEEL +4 -0
- mgf_livepush-0.1.2.dist-info/licenses/LICENSE +21 -0
mgf/livepush/__init__.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""mgf-livepush — realtime push pipeline (SSE + pluggable pub/sub broker).
|
|
2
|
+
|
|
3
|
+
A `mgf-common` sibling under the `mgf.*` namespace. Public surface:
|
|
4
|
+
|
|
5
|
+
* :class:`SSEStreamService` / :class:`SSEStream` / :func:`sse_headers`
|
|
6
|
+
— the broker-agnostic SSE streaming primitive.
|
|
7
|
+
* :class:`Broker` / :class:`RedisBroker` / :class:`MockBroker` /
|
|
8
|
+
:class:`Subscription` — the pub/sub seam + adapters.
|
|
9
|
+
* :class:`SlotCap` — the per-key connection-slot cap (R-28 pattern).
|
|
10
|
+
* :class:`CapExceededError` / :class:`LivePushError` — typed errors
|
|
11
|
+
(subclass ``mgf.common.exceptions.AppError``).
|
|
12
|
+
|
|
13
|
+
The FastAPI ``StreamingResponse`` wrapper lives in
|
|
14
|
+
:mod:`mgf.livepush.fastapi` (requires the ``[fastapi]`` extra). See the
|
|
15
|
+
README for the dev/prod-proxy SSE recipe.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from mgf.livepush._broker import Broker, MockBroker, RedisBroker, Subscription
|
|
21
|
+
from mgf.livepush._errors import CapExceededError, LivePushError
|
|
22
|
+
from mgf.livepush._slotcap import SlotCap
|
|
23
|
+
from mgf.livepush._sse import SSEStream, SSEStreamService, sse_headers
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"Broker",
|
|
27
|
+
"CapExceededError",
|
|
28
|
+
"LivePushError",
|
|
29
|
+
"MockBroker",
|
|
30
|
+
"RedisBroker",
|
|
31
|
+
"SSEStream",
|
|
32
|
+
"SSEStreamService",
|
|
33
|
+
"SlotCap",
|
|
34
|
+
"Subscription",
|
|
35
|
+
"sse_headers",
|
|
36
|
+
]
|
mgf/livepush/_broker.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Pluggable pub/sub broker seam for live-push fan-out.
|
|
2
|
+
|
|
3
|
+
`Broker` is the port; `RedisBroker` (gated behind the `[redis]` extra)
|
|
4
|
+
is the production adapter and `MockBroker` is the in-process test
|
|
5
|
+
double. A consumer that wants Redis Streams / NATS / Kafka writes a new
|
|
6
|
+
adapter — `SSEStreamService` never sees the broker's internals.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import contextlib
|
|
13
|
+
from typing import Any, Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
__all__ = ["Broker", "MockBroker", "RedisBroker", "Subscription"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@runtime_checkable
|
|
19
|
+
class Subscription(Protocol):
|
|
20
|
+
"""A live subscription to one channel."""
|
|
21
|
+
|
|
22
|
+
async def get_message(self, *, timeout: float) -> bytes | None:
|
|
23
|
+
"""Return the next message payload, or ``None`` on timeout."""
|
|
24
|
+
...
|
|
25
|
+
|
|
26
|
+
async def close(self) -> None:
|
|
27
|
+
"""Release the subscription (unsubscribe + free resources)."""
|
|
28
|
+
...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@runtime_checkable
|
|
32
|
+
class Broker(Protocol):
|
|
33
|
+
"""A pub/sub backend behind a two-method port."""
|
|
34
|
+
|
|
35
|
+
async def publish(self, channel: str, payload: str | bytes) -> int:
|
|
36
|
+
"""Publish ``payload`` to ``channel``; return the delivery count."""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
async def subscribe(self, channel: str) -> Subscription:
|
|
40
|
+
"""Open a :class:`Subscription` to ``channel``."""
|
|
41
|
+
...
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ---------------------------------------------------------------------------
|
|
45
|
+
# Redis adapter (requires the `[redis]` extra)
|
|
46
|
+
# ---------------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class _RedisSubscription:
|
|
50
|
+
def __init__(self, pubsub: Any, channel: str) -> None:
|
|
51
|
+
self._pubsub = pubsub
|
|
52
|
+
self._channel = channel
|
|
53
|
+
|
|
54
|
+
async def get_message(self, *, timeout: float) -> bytes | None:
|
|
55
|
+
msg = await self._pubsub.get_message(
|
|
56
|
+
ignore_subscribe_messages=True, timeout=timeout
|
|
57
|
+
)
|
|
58
|
+
if msg is None:
|
|
59
|
+
return None
|
|
60
|
+
data = msg.get("data")
|
|
61
|
+
if isinstance(data, bytes):
|
|
62
|
+
return data
|
|
63
|
+
if isinstance(data, str):
|
|
64
|
+
return data.encode("utf-8")
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
async def close(self) -> None:
|
|
68
|
+
with contextlib.suppress(Exception):
|
|
69
|
+
await self._pubsub.unsubscribe(self._channel)
|
|
70
|
+
await self._pubsub.aclose()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class RedisBroker:
|
|
74
|
+
"""Redis pub/sub adapter. Pass a ``redis.asyncio.Redis`` client."""
|
|
75
|
+
|
|
76
|
+
def __init__(self, redis: Any) -> None:
|
|
77
|
+
self._redis = redis
|
|
78
|
+
|
|
79
|
+
async def publish(self, channel: str, payload: str | bytes) -> int:
|
|
80
|
+
return int(await self._redis.publish(channel, payload))
|
|
81
|
+
|
|
82
|
+
async def subscribe(self, channel: str) -> Subscription:
|
|
83
|
+
pubsub = self._redis.pubsub()
|
|
84
|
+
await pubsub.subscribe(channel)
|
|
85
|
+
return _RedisSubscription(pubsub, channel)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
# ---------------------------------------------------------------------------
|
|
89
|
+
# In-process mock adapter (for consumers' tests)
|
|
90
|
+
# ---------------------------------------------------------------------------
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _MockSubscription:
|
|
94
|
+
def __init__(self) -> None:
|
|
95
|
+
self._queue: asyncio.Queue[bytes] = asyncio.Queue()
|
|
96
|
+
self._closed = False
|
|
97
|
+
|
|
98
|
+
async def get_message(self, *, timeout: float) -> bytes | None:
|
|
99
|
+
try:
|
|
100
|
+
return await asyncio.wait_for(self._queue.get(), timeout)
|
|
101
|
+
except TimeoutError:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
async def close(self) -> None:
|
|
105
|
+
self._closed = True
|
|
106
|
+
|
|
107
|
+
def deliver(self, data: bytes) -> None:
|
|
108
|
+
if not self._closed:
|
|
109
|
+
self._queue.put_nowait(data)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class MockBroker:
|
|
113
|
+
"""Deterministic in-process broker. Only delivers to subscribers that
|
|
114
|
+
were subscribed BEFORE the publish (matching real pub/sub semantics)."""
|
|
115
|
+
|
|
116
|
+
def __init__(self) -> None:
|
|
117
|
+
self._subs: dict[str, list[_MockSubscription]] = {}
|
|
118
|
+
|
|
119
|
+
async def publish(self, channel: str, payload: str | bytes) -> int:
|
|
120
|
+
data = payload.encode("utf-8") if isinstance(payload, str) else payload
|
|
121
|
+
subs = self._subs.get(channel, [])
|
|
122
|
+
for sub in subs:
|
|
123
|
+
sub.deliver(data)
|
|
124
|
+
return len(subs)
|
|
125
|
+
|
|
126
|
+
async def subscribe(self, channel: str) -> Subscription:
|
|
127
|
+
sub = _MockSubscription()
|
|
128
|
+
self._subs.setdefault(channel, []).append(sub)
|
|
129
|
+
return sub
|
mgf/livepush/_errors.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""mgf-livepush typed errors.
|
|
2
|
+
|
|
3
|
+
All subclass ``mgf.common.exceptions.AppError`` so a consumer's
|
|
4
|
+
top-level handler treats them uniformly (rule EH-01).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from mgf.common.exceptions import AppError
|
|
10
|
+
|
|
11
|
+
__all__ = ["CapExceededError", "LivePushError"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class LivePushError(AppError):
|
|
15
|
+
"""Base class for every mgf-livepush error."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class CapExceededError(LivePushError):
|
|
19
|
+
"""The per-key connection-slot cap is exhausted.
|
|
20
|
+
|
|
21
|
+
The caller should refuse the new stream (HTTP 429). Carries the
|
|
22
|
+
``slot_key`` + ``cap`` for logging / the ``Retry-After`` response.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, slot_key: str, cap: int) -> None:
|
|
26
|
+
self.slot_key = slot_key
|
|
27
|
+
self.cap = cap
|
|
28
|
+
super().__init__(
|
|
29
|
+
f"live-push slot cap of {cap} concurrent connections "
|
|
30
|
+
f"exceeded for {slot_key!r}"
|
|
31
|
+
)
|
mgf/livepush/_slotcap.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Per-key connection-slot cap over a Redis counter (the R-28 pattern).
|
|
2
|
+
|
|
3
|
+
INCR on acquire, DECR on release, with a TTL so a crashed process can't
|
|
4
|
+
leak a slot forever. Best-effort + **fail-OPEN**: a Redis outage lets
|
|
5
|
+
connections through rather than locking everyone out (the push path is
|
|
6
|
+
already degraded in an outage; denying authenticated users on top is the
|
|
7
|
+
wrong failure mode). Optional ``on_acquire`` / ``on_release`` callbacks
|
|
8
|
+
let a consumer wire its own observability without this module importing
|
|
9
|
+
it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from typing import TYPE_CHECKING, Any
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
|
|
20
|
+
__all__ = ["SlotCap"]
|
|
21
|
+
|
|
22
|
+
LOG = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class SlotCap:
|
|
26
|
+
def __init__(
|
|
27
|
+
self,
|
|
28
|
+
redis: Any,
|
|
29
|
+
*,
|
|
30
|
+
cap: int = 20,
|
|
31
|
+
key_prefix: str = "livepush:slot:",
|
|
32
|
+
ttl_seconds: int = 300,
|
|
33
|
+
on_acquire: Callable[[str], None] | None = None,
|
|
34
|
+
on_release: Callable[[str], None] | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self._redis = redis
|
|
37
|
+
self.cap = cap
|
|
38
|
+
self._prefix = key_prefix
|
|
39
|
+
self._ttl = ttl_seconds
|
|
40
|
+
self._on_acquire = on_acquire
|
|
41
|
+
self._on_release = on_release
|
|
42
|
+
|
|
43
|
+
def _key(self, slot_key: str) -> str:
|
|
44
|
+
return f"{self._prefix}{slot_key}"
|
|
45
|
+
|
|
46
|
+
async def acquire(self, slot_key: str) -> bool:
|
|
47
|
+
"""Reserve one slot for ``slot_key``. Returns ``False`` if the cap
|
|
48
|
+
is reached (caller refuses); ``True`` otherwise (caller MUST
|
|
49
|
+
eventually :meth:`release`). Fails OPEN (returns ``True``) on any
|
|
50
|
+
Redis error."""
|
|
51
|
+
key = self._key(slot_key)
|
|
52
|
+
try:
|
|
53
|
+
count = await self._redis.incr(key)
|
|
54
|
+
if count == 1:
|
|
55
|
+
await self._redis.expire(key, self._ttl)
|
|
56
|
+
if count > self.cap:
|
|
57
|
+
await self._redis.decr(key)
|
|
58
|
+
return False
|
|
59
|
+
except Exception:
|
|
60
|
+
LOG.warning(
|
|
61
|
+
"livepush: slot acquire failed; failing open",
|
|
62
|
+
exc_info=True,
|
|
63
|
+
extra={
|
|
64
|
+
"slot_key": slot_key,
|
|
65
|
+
"cap": self.cap,
|
|
66
|
+
"fingerprint": f"livepush.slot_acquire_failed:{slot_key}",
|
|
67
|
+
},
|
|
68
|
+
)
|
|
69
|
+
return True
|
|
70
|
+
if self._on_acquire is not None:
|
|
71
|
+
self._on_acquire(slot_key)
|
|
72
|
+
return True
|
|
73
|
+
|
|
74
|
+
async def release(self, slot_key: str) -> None:
|
|
75
|
+
"""Release one slot for ``slot_key``. No-op on Redis failure;
|
|
76
|
+
floors the counter at 0."""
|
|
77
|
+
key = self._key(slot_key)
|
|
78
|
+
try:
|
|
79
|
+
count = await self._redis.decr(key)
|
|
80
|
+
if count is not None and count < 0:
|
|
81
|
+
await self._redis.set(key, 0, ex=self._ttl)
|
|
82
|
+
except Exception:
|
|
83
|
+
LOG.warning(
|
|
84
|
+
"livepush: slot release failed (best-effort)",
|
|
85
|
+
exc_info=True,
|
|
86
|
+
extra={
|
|
87
|
+
"slot_key": slot_key,
|
|
88
|
+
"fingerprint": f"livepush.slot_release_failed:{slot_key}",
|
|
89
|
+
},
|
|
90
|
+
)
|
|
91
|
+
if self._on_release is not None:
|
|
92
|
+
self._on_release(slot_key)
|
mgf/livepush/_sse.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Broker-agnostic SSE streaming primitive.
|
|
2
|
+
|
|
3
|
+
`SSEStreamService.open_stream` acquires a slot (raising
|
|
4
|
+
:class:`CapExceededError` *synchronously* if the cap is full — so a
|
|
5
|
+
consumer maps it to 429 BEFORE opening the stream), subscribes via the
|
|
6
|
+
broker, and returns an :class:`SSEStream` (headers + an async byte
|
|
7
|
+
iterator). The iterator emits the ``retry:`` directive, heartbeat
|
|
8
|
+
comments, and ``data:`` frames, and in a ``finally`` block releases the
|
|
9
|
+
slot + closes the subscription on disconnect / cancel / error.
|
|
10
|
+
|
|
11
|
+
Framework-agnostic: the consumer supplies an async ``is_disconnected``
|
|
12
|
+
callable (FastAPI's ``request.is_disconnected``) and wraps the iterator
|
|
13
|
+
in whatever streaming response its framework uses.
|
|
14
|
+
:mod:`mgf.livepush.fastapi` ships a one-line wrapper.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import asyncio
|
|
20
|
+
import contextlib
|
|
21
|
+
import logging
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import TYPE_CHECKING
|
|
24
|
+
|
|
25
|
+
from mgf.livepush._errors import CapExceededError
|
|
26
|
+
|
|
27
|
+
if TYPE_CHECKING:
|
|
28
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
29
|
+
|
|
30
|
+
from mgf.livepush._broker import Broker, Subscription
|
|
31
|
+
from mgf.livepush._slotcap import SlotCap
|
|
32
|
+
|
|
33
|
+
__all__ = ["SSEStream", "SSEStreamService", "sse_headers"]
|
|
34
|
+
|
|
35
|
+
LOG = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
# EventSource reconnect cadence (ms). Pairs with a 429 Retry-After: 5 so
|
|
38
|
+
# a flapping client doesn't tighten into a hot loop against the cap.
|
|
39
|
+
_RETRY_MS = 5000
|
|
40
|
+
# Heartbeat envelope — survives AWS ALB / Cloudflare 30 s idle-close.
|
|
41
|
+
_DEFAULT_HEARTBEAT_SECONDS = 15.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def sse_headers() -> dict[str, str]:
|
|
45
|
+
"""Headers every SSE response needs so proxies pass the wire
|
|
46
|
+
untouched (disable caching/transform + Nginx-style buffering)."""
|
|
47
|
+
return {"Cache-Control": "no-cache, no-transform", "X-Accel-Buffering": "no"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class SSEStream:
|
|
52
|
+
"""The result of :meth:`SSEStreamService.open_stream` — the headers to
|
|
53
|
+
set on the response + the byte iterator to stream as its body."""
|
|
54
|
+
|
|
55
|
+
headers: dict[str, str]
|
|
56
|
+
body: AsyncIterator[bytes]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class SSEStreamService:
|
|
60
|
+
"""Per-app SSE primitive over a :class:`Broker` + :class:`SlotCap`."""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
broker: Broker,
|
|
65
|
+
slot_cap: SlotCap,
|
|
66
|
+
*,
|
|
67
|
+
heartbeat_seconds: float = _DEFAULT_HEARTBEAT_SECONDS,
|
|
68
|
+
) -> None:
|
|
69
|
+
self._broker = broker
|
|
70
|
+
self._slot_cap = slot_cap
|
|
71
|
+
self._heartbeat_s = heartbeat_seconds
|
|
72
|
+
|
|
73
|
+
async def open_stream(
|
|
74
|
+
self,
|
|
75
|
+
*,
|
|
76
|
+
channel: str,
|
|
77
|
+
slot_key: str,
|
|
78
|
+
is_disconnected: Callable[[], Awaitable[bool]],
|
|
79
|
+
) -> SSEStream:
|
|
80
|
+
"""Acquire a slot, subscribe to ``channel``, return an
|
|
81
|
+
:class:`SSEStream`. Raises :class:`CapExceededError` synchronously
|
|
82
|
+
when the cap is full — no subscription / slot leak (the acquire
|
|
83
|
+
happens before any pubsub state)."""
|
|
84
|
+
acquired = await self._slot_cap.acquire(slot_key)
|
|
85
|
+
if not acquired:
|
|
86
|
+
raise CapExceededError(slot_key, self._slot_cap.cap)
|
|
87
|
+
sub = await self._broker.subscribe(channel)
|
|
88
|
+
return SSEStream(
|
|
89
|
+
headers=sse_headers(),
|
|
90
|
+
body=self._event_gen(sub, slot_key, is_disconnected),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
async def _event_gen(
|
|
94
|
+
self,
|
|
95
|
+
sub: Subscription,
|
|
96
|
+
slot_key: str,
|
|
97
|
+
is_disconnected: Callable[[], Awaitable[bool]],
|
|
98
|
+
) -> AsyncIterator[bytes]:
|
|
99
|
+
try:
|
|
100
|
+
yield f"retry: {_RETRY_MS}\n\n".encode()
|
|
101
|
+
while True:
|
|
102
|
+
data = await sub.get_message(timeout=self._heartbeat_s)
|
|
103
|
+
if await is_disconnected():
|
|
104
|
+
return
|
|
105
|
+
if data is None:
|
|
106
|
+
yield b": keep-alive\n\n"
|
|
107
|
+
continue
|
|
108
|
+
yield b"data: " + data + b"\n\n"
|
|
109
|
+
except asyncio.CancelledError:
|
|
110
|
+
return
|
|
111
|
+
finally:
|
|
112
|
+
# Close the subscription BEFORE releasing the slot: if release
|
|
113
|
+
# came first and close hung, the slot would be reusable before
|
|
114
|
+
# the underlying resource was freed (slot-leak adjacent).
|
|
115
|
+
with contextlib.suppress(Exception):
|
|
116
|
+
await sub.close()
|
|
117
|
+
await self._slot_cap.release(slot_key)
|
mgf/livepush/fastapi.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Optional FastAPI adapter (requires the ``[fastapi]`` extra).
|
|
2
|
+
|
|
3
|
+
One line in a route handler turns an :class:`SSEStreamService` into a
|
|
4
|
+
``StreamingResponse``, mapping the cap-exceeded case to a 429 with a
|
|
5
|
+
``Retry-After`` header.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
from mgf.livepush._errors import CapExceededError
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from fastapi import Request
|
|
16
|
+
from fastapi.responses import StreamingResponse
|
|
17
|
+
|
|
18
|
+
from mgf.livepush._sse import SSEStreamService
|
|
19
|
+
|
|
20
|
+
__all__ = ["sse_streaming_response"]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
async def sse_streaming_response(
|
|
24
|
+
service: SSEStreamService,
|
|
25
|
+
request: Request,
|
|
26
|
+
*,
|
|
27
|
+
channel: str,
|
|
28
|
+
slot_key: str,
|
|
29
|
+
) -> StreamingResponse:
|
|
30
|
+
"""Open an SSE stream and wrap it in a FastAPI ``StreamingResponse``.
|
|
31
|
+
|
|
32
|
+
Raises ``HTTPException(429)`` with ``Retry-After: 5`` when the
|
|
33
|
+
per-key slot cap is exhausted.
|
|
34
|
+
"""
|
|
35
|
+
from fastapi import HTTPException
|
|
36
|
+
from fastapi.responses import StreamingResponse
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
stream = await service.open_stream(
|
|
40
|
+
channel=channel,
|
|
41
|
+
slot_key=slot_key,
|
|
42
|
+
is_disconnected=request.is_disconnected,
|
|
43
|
+
)
|
|
44
|
+
except CapExceededError as exc:
|
|
45
|
+
raise HTTPException(
|
|
46
|
+
status_code=429, detail=str(exc), headers={"Retry-After": "5"}
|
|
47
|
+
) from exc
|
|
48
|
+
return StreamingResponse(
|
|
49
|
+
stream.body, media_type="text/event-stream", headers=stream.headers
|
|
50
|
+
)
|
mgf/livepush/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mgf-livepush
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: Realtime push pipeline for mgf-common consumers — SSE streaming, a pluggable pub/sub broker seam (Redis + mock), and per-key connection-slot caps. Sibling of mgf-common under the mgf.* namespace.
|
|
5
|
+
Author: Bassam Alsanie, mgf-livepush contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: fastapi,pubsub,realtime,redis,sse,streaming
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
16
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: mgf-common<0.43,>=0.41
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: import-linter>=2.0; extra == 'dev'
|
|
22
|
+
Requires-Dist: mgf-test-supervisor<0.2,>=0.1.2; extra == 'dev'
|
|
23
|
+
Requires-Dist: mypy>=1.10; extra == 'dev'
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest-cov>=5.0; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-timeout>=2.3; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: ruff>=0.4; extra == 'dev'
|
|
29
|
+
Provides-Extra: fastapi
|
|
30
|
+
Requires-Dist: fastapi>=0.110; extra == 'fastapi'
|
|
31
|
+
Provides-Extra: redis
|
|
32
|
+
Requires-Dist: redis>=5.0; extra == 'redis'
|
|
33
|
+
Provides-Extra: test
|
|
34
|
+
Requires-Dist: fastapi>=0.110; extra == 'test'
|
|
35
|
+
Requires-Dist: redis>=5.0; extra == 'test'
|
|
36
|
+
Description-Content-Type: text/markdown
|
|
37
|
+
|
|
38
|
+
# mgf-livepush
|
|
39
|
+
|
|
40
|
+
Realtime push pipeline for `mgf-common` consumers: an SSE streaming
|
|
41
|
+
primitive, a pluggable pub/sub **broker seam** (Redis adapter + an
|
|
42
|
+
in-process mock), and a per-key connection-**slot cap**. A sibling of
|
|
43
|
+
`mgf-common` under the `mgf.*` namespace.
|
|
44
|
+
|
|
45
|
+
Extracted from PlasmaMapper's Phase-4 push surface after the
|
|
46
|
+
pre-release core re-design hardened it (heartbeat cadence, slot
|
|
47
|
+
lifecycle, disconnect cleanup, and the dev/prod-proxy buffering recipe
|
|
48
|
+
below). The broker seam means a consumer can swap Redis pub/sub for
|
|
49
|
+
Redis Streams / NATS without touching the SSE machinery.
|
|
50
|
+
|
|
51
|
+
## Install
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
pip install "mgf-livepush[redis,fastapi]" # adapters are opt-in extras
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quickstart (FastAPI)
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from redis.asyncio import Redis
|
|
61
|
+
from mgf.livepush import SSEStreamService, RedisBroker, SlotCap
|
|
62
|
+
from mgf.livepush.fastapi import sse_streaming_response
|
|
63
|
+
|
|
64
|
+
redis = Redis.from_url("redis://localhost:6379/0")
|
|
65
|
+
sse = SSEStreamService(RedisBroker(redis), SlotCap(redis, cap=20))
|
|
66
|
+
|
|
67
|
+
@router.get("/events/stream")
|
|
68
|
+
async def stream(request: Request):
|
|
69
|
+
# 429 (Retry-After: 5) automatically when the per-key cap is full.
|
|
70
|
+
return await sse_streaming_response(
|
|
71
|
+
sse, request, channel=f"events:{tenant_id}", slot_key=str(tenant_id)
|
|
72
|
+
)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Publisher side, anywhere:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
await RedisBroker(redis).publish(f"events:{tenant_id}", json.dumps(event))
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Framework-agnostic core: `open_stream(...)` returns an `SSEStream`
|
|
82
|
+
(`.headers` + `.body` async byte iterator) — wrap `.body` in whatever
|
|
83
|
+
streaming response your framework uses. Tests use `MockBroker` (no
|
|
84
|
+
Redis).
|
|
85
|
+
|
|
86
|
+
## The dev/prod-proxy SSE recipe (read this — it's the tarpit)
|
|
87
|
+
|
|
88
|
+
A FastAPI `text/event-stream` endpoint behind a proxy (SvelteKit `vite`,
|
|
89
|
+
a `+server.ts` `[...path]` forward, nginx, AWS ALB) will silently
|
|
90
|
+
**buffer** the body and break your latency budget even though the API
|
|
91
|
+
side is correct. To get live push through a proxy:
|
|
92
|
+
|
|
93
|
+
1. **Response headers (set by this lib):** `Cache-Control: no-cache,
|
|
94
|
+
no-transform` + `X-Accel-Buffering: no`. If your proxy gzips, also
|
|
95
|
+
force `Content-Encoding: identity` on the proxied response.
|
|
96
|
+
2. **Node `fetch` forwarding** of a streamed body needs `duplex: 'half'`.
|
|
97
|
+
3. **Playwright:** `waitForLoadState('networkidle')` *never fires* with
|
|
98
|
+
an open `EventSource` (the long-lived connection counts as in-flight)
|
|
99
|
+
— wait on a concrete readiness signal instead.
|
|
100
|
+
4. **Playwright:** `webServer` spawns *before* `globalSetup`, so any env
|
|
101
|
+
the proxy needs must be declared statically in `webServer.env`, not
|
|
102
|
+
threaded from `globalSetup`.
|
|
103
|
+
|
|
104
|
+
## Public surface
|
|
105
|
+
|
|
106
|
+
| Name | What |
|
|
107
|
+
|---|---|
|
|
108
|
+
| `SSEStreamService` | acquire slot → subscribe → stream (`open_stream`) |
|
|
109
|
+
| `SSEStream` / `sse_headers` | the `(headers, body)` result + the proxy-safe headers |
|
|
110
|
+
| `Broker` / `Subscription` | the pub/sub port (Protocols) |
|
|
111
|
+
| `RedisBroker` / `MockBroker` | adapters (`[redis]` extra / in-process) |
|
|
112
|
+
| `SlotCap` | per-key INCR/DECR connection cap, fail-open, TTL |
|
|
113
|
+
| `CapExceededError` / `LivePushError` | typed errors (subclass `AppError`) |
|
|
114
|
+
| `mgf.livepush.fastapi.sse_streaming_response` | one-line FastAPI wrapper (`[fastapi]` extra) |
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
mgf/livepush/__init__.py,sha256=qmWG8GjWYVH6R7nu-kN68YJs1Mbfv3in4FlnLh69KPU,1265
|
|
2
|
+
mgf/livepush/_broker.py,sha256=5idCJaM_hd__rqrW8WwOflWdkR9QTqpB8-zJHitV6jY,4181
|
|
3
|
+
mgf/livepush/_errors.py,sha256=jehN-qXpCIK0OtgLT65ab9s1Jqjc-9hUQJKRUreNDBQ,866
|
|
4
|
+
mgf/livepush/_slotcap.py,sha256=G2S5LEO2xiq0R1Wo_OIQsMLFl2xmHQZT63T4Ku-XCPg,3135
|
|
5
|
+
mgf/livepush/_sse.py,sha256=7ePvjxYLT56PDCdGqvCXO3l6W0VRMFScsOn9ygHo52M,4160
|
|
6
|
+
mgf/livepush/fastapi.py,sha256=mVNa_zE1l1b5LSwvnWRWXSdwVVajtR4Z8WqMFBKcHmc,1426
|
|
7
|
+
mgf/livepush/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
8
|
+
mgf_livepush-0.1.2.dist-info/METADATA,sha256=1RLh8KzO5IUDS-OiaZ9v71KGHKjJYypBeBWX2XwSZQU,4742
|
|
9
|
+
mgf_livepush-0.1.2.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
10
|
+
mgf_livepush-0.1.2.dist-info/licenses/LICENSE,sha256=608k_GqtNLHawes6fp-TlRhHA2Ha6rGGenwLZPN-j9U,1101
|
|
11
|
+
mgf_livepush-0.1.2.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bassam Alsanie and mgf-livepush contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|