fix(api): address review on the event-loop streams

- Per-user SSE cap uses per-connection leases in a sorted set instead of
  a shared INCR/DECR counter with a TTL. A stream that outlived the TTL
  could let the counter expire, then decrement another stream's slot or
  drive the count negative. Leases refresh while a stream sends frames
  and age out when a stream dies without cleanup.
- Shielded cleanup is bounded per step: stream close and on_close in
  ClosingStreamingResponse, unsubscribe and close in AsyncTopic, lease
  release, and the replay-budget check, so a dead Redis connection can't
  hold a request or a graceful shutdown.
- Artifact downloads send an ASCII filename plus an RFC 5987 filename*
  for non-ASCII names, build headers before opening the file, and close
  disk handles off the event loop.
- Type hints and docstrings on the new helpers.
This commit is contained in:
Alex committed 2026-09-14 10:19:59 +01:00
1 parent 54b540ea42
commit e889392eba
17 files changed
+911 -255

No files matched your search

+11 -6
View File
@@ -31,6 +31,11 @@ OnSubscribe = Callable[[], Union[None, Awaitable[None]]]
# payload, whose PONG the client swallows before ``get_message`` returns.
_LIVENESS_PING = "docsgpt-liveness"
# Upper bound on each teardown call (unsubscribe, close). Teardown is shielded
# from the cancellation that ended the stream, so without a bound a dead
# connection could hold the stream's task and a graceful shutdown.
_TEARDOWN_TIMEOUT_SECONDS = 5.0
class AsyncTopic:
"""An async pub/sub channel identified by a string name."""
@@ -145,12 +150,11 @@ class AsyncTopic:
yield data if isinstance(data, bytes) else str(data).encode("utf-8")
finally:
# Client disconnect cancels this generator at the ``await
# get_message`` above; without shielding, the cancellation could
# re-fire mid-teardown and skip ``aclose()``, leaking the pooled
# connection back to nothing. Shield so unsubscribe + aclose
# always complete and the connection returns to the pool.
with anyio.CancelScope(shield=True):
if on_subscribe_fired:
# get_message`` above. Each teardown call is shielded so the
# cancellation can't skip it and strand the pooled connection, and
# bounded separately so a stuck unsubscribe still lets the close run.
if on_subscribe_fired:
with anyio.move_on_after(_TEARDOWN_TIMEOUT_SECONDS, shield=True):
try:
await pubsub.unsubscribe(self.name)
except Exception:
@@ -159,6 +163,7 @@ class AsyncTopic:
self.name,
exc_info=True,
)
with anyio.move_on_after(_TEARDOWN_TIMEOUT_SECONDS, shield=True):
try:
await pubsub.aclose()
except Exception:
+129
View File
@@ -0,0 +1,129 @@
"""Per-connection leases behind the per-user SSE connection cap.
Each open stream holds a member in the sorted set ``user:{id}:sse_leases``,
scored by when it last refreshed. Dropping stale members and counting the rest
enforces ``SSE_MAX_CONCURRENT_PER_USER``. A stream refreshes its lease while it
runs and removes it when it ends; a lease whose stream died without cleanup
(SIGKILL, OOM) ages out after the lease TTL. Unlike a shared counter with a
TTL, an expiring lease can't take another stream's slot with it or drive the
count negative.
"""
from __future__ import annotations
import logging
import time
import uuid
from contextlib import aclosing
from dataclasses import dataclass
from typing import Any, AsyncIterator, Optional, TypeVar
import anyio
from docsgpt.core.settings import settings
from docsgpt.events.keys import connection_leases_key
logger = logging.getLogger(__name__)
# Upper bound on any one lease command, so a stalled Redis connection can't
# hold a request, a stream, or its cleanup.
_REDIS_OP_TIMEOUT_SECONDS = 5.0
T = TypeVar("T")
class StreamCapExceeded(Exception):
"""Raised when the user already holds ``SSE_MAX_CONCURRENT_PER_USER`` streams."""
def lease_ttl_seconds() -> float:
"""Seconds a lease survives without a refresh.
A stream yields at least once per keepalive interval and refreshes on the
first yield after a third of the TTL, so four keepalives of headroom keep a
live stream's lease from expiring.
"""
return max(60.0, 4.0 * float(settings.SSE_KEEPALIVE_SECONDS))
@dataclass
class StreamLease:
"""One stream's claim on a slot in its user's connection cap."""
redis: Any
key: str
lease_id: str
ttl: float
refreshed_at: float
async def refresh_if_due(self) -> None:
"""Re-stamp the lease once a third of its TTL has passed; failures are logged, not raised."""
now = time.monotonic()
if now - self.refreshed_at < self.ttl / 3:
return
self.refreshed_at = now
try:
with anyio.fail_after(_REDIS_OP_TIMEOUT_SECONDS):
async with self.redis.pipeline(transaction=True) as pipe:
pipe.zadd(self.key, {self.lease_id: time.time()})
pipe.expire(self.key, int(self.ttl))
await pipe.execute()
except Exception:
logger.debug("SSE lease refresh failed for key=%s", self.key, exc_info=True)
async def release(self) -> None:
"""Remove this lease; shielded so it completes during cancellation, and bounded."""
with anyio.move_on_after(_REDIS_OP_TIMEOUT_SECONDS, shield=True):
try:
await self.redis.zrem(self.key, self.lease_id)
except Exception:
logger.debug("SSE lease release failed for key=%s", self.key, exc_info=True)
async def acquire_stream_lease(redis: Any, user_id: str) -> Optional[StreamLease]:
"""Claim a connection slot for ``user_id``.
Args:
redis: The async Redis client, or ``None`` when unavailable.
user_id: The user opening the stream.
Returns:
The lease to refresh and release, or ``None`` when the cap is disabled
or Redis is unavailable or failing (fail-open).
Raises:
StreamCapExceeded: The user already holds the maximum number of streams.
"""
cap = int(settings.SSE_MAX_CONCURRENT_PER_USER)
if cap <= 0 or redis is None:
return None
key = connection_leases_key(user_id)
ttl = lease_ttl_seconds()
lease_id = uuid.uuid4().hex
now = time.time()
try:
with anyio.fail_after(_REDIS_OP_TIMEOUT_SECONDS):
# One transaction, so concurrent connects are counted one at a time.
async with redis.pipeline(transaction=True) as pipe:
pipe.zremrangebyscore(key, "-inf", now - ttl)
pipe.zadd(key, {lease_id: now})
pipe.zcard(key)
pipe.expire(key, int(ttl))
results = await pipe.execute()
except Exception:
logger.debug("SSE lease acquire failed for user=%s; failing open", user_id, exc_info=True)
return None
lease = StreamLease(redis=redis, key=key, lease_id=lease_id, ttl=ttl, refreshed_at=time.monotonic())
if int(results[2]) > cap:
await lease.release()
raise StreamCapExceeded()
return lease
async def hold_lease(stream: AsyncIterator[T], lease: Optional[StreamLease]) -> AsyncIterator[T]:
"""Yield from ``stream``, refreshing ``lease`` between frames; closing this closes ``stream``."""
async with aclosing(stream):
async for item in stream:
yield item
if lease is not None:
await lease.refresh_if_due()