Files
DocsGPT/docsgpt/streaming/sse_keepalive.py
T
Alex 574f96341e refactor: rename the application package to docsgpt
The backend import package is now docsgpt, the name it will carry on PyPI;
application was far too generic to install into anyone's site-packages.
git mv plus a mechanical rewrite of every import, dotted string and path
reference: 734 Python files, the compose files, Dockerfile, workflows, docs,
setup scripts, devcontainer, k8s manifests, vscode config, pytest and coverage
config, .gitignore. Behaviour is unchanged.

Kept for one release:
- A top-level application package whose meta-path finder resolves
  application.x.y to the already-imported docsgpt.x.y object, so old imports
  and entry points (celery -A application.app.celery,
  uvicorn application.asgi:asgi_app) keep working with a FutureWarning.
- Celery registers every application.* task name as an alias of its
  docsgpt.* task on start-up, so messages queued by the previous release still
  run. The redbeat key prefix moves to redbeat:docsgpt:v2: so schedule entries
  the previous release wrote are left unread instead of firing twice.

The backend image builds from the repository root (docker build -f
docsgpt/Dockerfile .) so it can ship the alias package; a root .dockerignore
allow-lists docsgpt/ and application/ and keeps caches, local data, .env
files, the sample index files and the Dockerfile out. Compose and the image
workflows point at the new context.
2026-09-07 10:20:43 +01:00

73 lines
2.8 KiB
Python

"""Wire-level keepalive wrapper for synchronous SSE streaming routes."""
import contextvars
import logging
import queue
import threading
from typing import Generator, Optional
from docsgpt.core.settings import settings
logger = logging.getLogger(__name__)
def with_sse_keepalive(
inner: Generator[str, None, None],
interval_seconds: Optional[float] = None,
) -> Generator[str, None, None]:
"""Yield from ``inner``, emitting ``: keepalive`` SSE comments whenever it
stays quiet for ``interval_seconds`` (defaults to
``settings.SSE_KEEPALIVE_SECONDS``).
Long tool-argument generations, summary-less reasoning stretches, and
server-side tool runs keep the origin busy for minutes without producing
a single chunk; proxies in front of the API then cut the byte-silent
connection (Cloudflare read-times-out at ~100s) while the origin
finishes into a dead socket. SSE comment frames keep bytes flowing and
are ignored by every SSE/OpenAI-compatible client — the same
``: keepalive`` convention used by
``docsgpt/streaming/async_event_replay.py``.
``inner`` is pumped from a daemon thread so this wrapper can time out on
silence. Callers must pass generators that don't rely on Flask's request
context (both chat stream routes return bare generators without
``stream_with_context``). The pump runs in a copy of the caller's
contextvars context so request-scoped log bindings survive the thread
hop. The queue is unbounded and the thread is a daemon: if the consumer
disconnects mid-stream, the pump drains the upstream generator to
completion — matching the production ASGI adapter's pre-existing
disconnect behavior, where the WSGI iterable is drained before close().
"""
if interval_seconds is None:
interval_seconds = float(settings.SSE_KEEPALIVE_SECONDS)
frames: queue.Queue = queue.Queue()
def _pump() -> None:
try:
for item in inner:
frames.put(("item", item))
except BaseException as exc:
# Logged here because after a client disconnect the consumer
# loop is gone and nothing ever dequeues (or re-raises) this.
logger.exception("SSE keepalive pump: upstream stream failed")
frames.put(("error", exc))
else:
frames.put(("end", None))
ctx = contextvars.copy_context()
threading.Thread(
target=ctx.run, args=(_pump,), daemon=True, name="sse-keepalive-pump"
).start()
while True:
try:
kind, payload = frames.get(timeout=interval_seconds)
except queue.Empty:
yield ": keepalive\n\n"
continue
if kind == "item":
yield payload
elif kind == "error":
raise payload
else:
return