main
py 65 lines 2.57 KB
Raw
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """SSE keep-alive comments for idle streaming responses.
4
5 Reverse proxies and tunnels (Cloudflare Tunnel, NGINX ``proxy_read_timeout``,
6 AWS ALB, ...) can close a streaming connection when no bytes are sent for a
7 while. That happens while a request is queued, during prefill, or in gaps
8 between tokens. ``with_sse_keep_alive`` wraps the final SSE generator and emits
9 a comment line (``": keep-alive\\n\\n"``) whenever it has been idle for
10 ``interval`` seconds. Comments are ignored by SSE-compliant clients but still
11 count as bytes for proxy read-timeout logic.
12 """
13
14 import asyncio
15 import contextlib
16 from collections.abc import AsyncGenerator
17
18 SSE_KEEP_ALIVE_COMMENT = ": keep-alive\n\n"
19
20
21 def with_sse_keep_alive(
22 generator: AsyncGenerator[str, None],
23 interval: float,
24 ) -> AsyncGenerator[str, None]:
25 """Emit an SSE keep-alive comment when ``generator`` is idle.
26
27 A non-positive or non-finite ``interval`` returns ``generator`` unchanged,
28 so the default path has no overhead. Otherwise a keep-alive comment is
29 yielded whenever no chunk arrives within ``interval`` seconds.
30 """
31 if not 0 < interval < float("inf"):
32 return generator
33 return _keep_alive_stream(generator, interval)
34
35
36 async def _keep_alive_stream(
37 generator: AsyncGenerator[str, None],
38 interval: float,
39 ) -> AsyncGenerator[str, None]:
40 # A single retained ``anext()`` task advances the generator; asyncio forbids
41 # concurrent ``__anext__`` calls, and ``wait_for`` would cancel the task
42 # mid-advance on timeout. ``asyncio.wait`` observes both outcomes without
43 # cancelling the pending advance.
44 next_task: asyncio.Task[str] = asyncio.create_task(anext(generator))
45 try:
46 while True:
47 done, _ = await asyncio.wait({next_task}, timeout=interval)
48 if not done:
49 yield SSE_KEEP_ALIVE_COMMENT
50 continue
51 try:
52 chunk = next_task.result()
53 except StopAsyncIteration:
54 return
55 yield chunk
56 next_task = asyncio.create_task(anext(generator))
57 finally:
58 next_task.cancel()
59 # The task outcome was already handled in the loop; suppress so cleanup
60 # never corrupts it (matching ``merge_async_iterators``).
61 with contextlib.suppress(BaseException):
62 await next_task
63 # Close the upstream generator so its cleanup runs deterministically.
64 with contextlib.suppress(BaseException):
65 await generator.aclose()