Repository navigation
streaming: optional content-idle timeout that keepalives cannot reset (reference implementation for #2051) - #2052
simin-yuan wants to merge 5 commits into
Conversation
A comment frame or a ping event resets the socket read timeout without moving the response forward, so a stalled stream and a slow one are indistinguishable to the caller. This adds a byte-level guard: if no event carrying content arrives for N seconds, the stream raises APITimeoutError even though bytes keep flowing. Comment lines and ping events do not count as content; error frames do, because the caller can act on them. Opt-in, no change to existing behaviour. Refs: the issue this was written for (see the PR description).
ruff check and ruff format both pass on the three files added by this PR; 12 tests still pass.
mangeshraut712
left a comment
There was a problem hiding this comment.
Solid guard design — keepalives resetting a read timer while content never arrives is a real blind spot, and the mid-frame tail is a nice touch. One issue in _KeepaliveTracker.has_content: it only splits on \n, but the SSE spec (WHATWG) defines CR, LF and CRLF all as valid line terminators.
Consequences for a server that terminates lines with a bare \r:
- The frame never completes a
split(b"\n")line, so_tailis never emptied — each chunk is appended until the stream ends, so an otherwise idle-but-alive stream grows_tailwithout bound (memory leak on long keepalive streams). - A
data:line carrying real content, terminated with\r, is never parsed as content, sohas_contentreturns False even though the response is moving forward. If the gap between two CR-terminated frames exceedsseconds, the guard raises a false APITimeoutError on a healthy stream.
Suggest splitting on a bytes regex that accepts \r\n?/\n (or normalizing \r to \n before split) and a test with CR-only keepalive and content frames. Also worth a guard test with a chunk ending exactly at a frame boundary mid-data: payload with the tail non-empty across >2 chunks, since _tail + chunk concatenation is what the parser depends on.
_KeepaliveTracker split on b"\n" only, but the SSE spec allows CR, LF and CRLF as line terminators. The SDK's own decoder already agrees: SSEDecoder accumulates bytes up to an event boundary and then calls bytes.splitlines(), which breaks on exactly those three (the comment above iter_bytes says as much), and it exposes no standalone line splitter to import - so the same rule is spelled out locally rather than left inconsistent with the decoder. For a server that terminates lines with a bare CR that meant a frame never completed a line here: _tail kept every chunk for the life of the stream (memory growth on a long keepalive stream), and a data: line carrying real content was never classified, so a healthy stream could raise APITimeoutError. - split on rb"\r\n|\r|\n", with CRLF as one terminator and not two; - hold back a trailing bare CR, because the next chunk may start with LF and ending the line there would invent an empty line (an empty line ends an SSE event, so the guard would be reading a different stream from the decoder); - cap the pending line at 64 KiB and count anything longer as content. The whole line is needed only to parse a data: payload, and keepalives are a few dozen bytes, so this costs at most one missed check on a pathological stream while bounding the buffer. Tests: CR-only comment, ping and content frames (sync and async), a CRLF split across two chunks, a data: line spread over three chunks, and an unterminated line that reaches the cap. Four of the new tests fail on the previous implementation: the sync and async false timeouts, the unbounded _tail, and the uncapped pending line. Refs: anthropics#2051
|
Thanks — you're right, and I checked it against the SDK's own decoder before changing anything. What the decoder actually does. CRLF across a chunk boundary. A chunk that ends in a bare CR is held back instead of being treated as a finished line, because the next chunk may start with LF and together they are one terminator. Ending the line at the CR would make the incoming LF read as an extra blank line — and a blank line ends an SSE event, so the guard would be parsing a different stream from the decoder. Buffer bound. Classification needs the whole line only because a Tests (
Four of the new tests fail against the previous implementation: the sync and async false timeouts, the unbounded Full suite on this machine ( Commit: simin-yuan@cf8bcad Thanks for catching the CR case — the tail growth would only surface against a server that uses it, which is exactly the case that never gets tested. — Simin Yuan |
Holding back a trailing bare CR assumed that a CRLF split across two chunks would otherwise turn the LF into an extra blank line. The tracker classifies one line at a time and _frame_is_keepalive treats an empty line as a keepalive, so that extra line cannot change the answer - while holding the CR did delay counting a CR-terminated content line until the next chunk, a small version of the false-timeout this guard exists to avoid. Lines now end where the CR arrives: no carry state, one branch fewer. The 64 KiB cap on the pending line is unchanged. The split-CRLF content test fails on cf8bcad, where has_content returns False for a content line that ends the chunk, and passes here. Refs: anthropics#2051
|
Follow-up in simin-yuan@6bec8e2: I removed the held-back trailing CR. The tracker classifies line by line and an empty line is always a keepalive, so a CRLF split across chunks cannot change the result — while holding the CR delayed counting a CR-terminated content line until the next chunk. Tests updated accordingly; the split-content test now fails on cf8bcad and passes here. Single-file run: 22 passed on Python 3.10. Since this is my first contribution, the workflows need a maintainer's approval to run — thanks in advance. — Simin Yuan |
|
@mangeshraut712 thanks for the review. Bare CR is handled now: lines end at CR, LF or CRLF ( |
Reference implementation for #2051.
timeout=bounds a single socket read. A server that sends: pingcomments orpingevents resetsthat timer without ever moving the response forward, so a stalled stream and a slow one are
indistinguishable to the caller.
This adds an opt-in guard that counts content instead of bytes: if no content event arrives for N
seconds, the stream raises
APITimeoutError, even though keepalives are still arriving.It is not re-exported from
anthropic.lib.streamingin this PR — one explicit import instead ofexpanding the public surface before the parameter shape is settled (see "Not in this PR").
Where it sits
The guard wraps the response's byte stream rather than the parsed events, because the SDK discards
keepalive frames while parsing — by the time an event reaches a caller, the information that a
keepalive arrived is already gone. Comment lines and
pingevents do not count as content;errorframes do, since the caller can act on them.
Two limits, stated rather than hidden: the check runs when a chunk arrives, so a server that stops
sending bytes entirely is still the transport read timeout's job; and a frame split across two chunks
is reassembled in a bounded buffer before being classified. Lines end at CR, LF or CRLF, the same
rule
SSEDecoderapplies when it callsbytes.splitlines(), and the buffer is capped at 64 KiB, soa server that never terminates a line cannot grow it without bound.
Tests
tests/lib/streaming/test_idle_timeout.py, 22 tests, 22 passed locally (Python 3.10.20):: ping, the frame LiteLLM's proxy writes) raises on the guard's own clock,not at the end of the body;
ping-event-only stream does the same;errorframe counts as content;AnthropicandAsyncAnthropicwith a mock transport;content does, on both the sync and async paths;
_tailis empty after every frame;blank line, which is a keepalive either way, and a content line that ends the chunk is counted at once;
data:line spread over three chunks is recognised as content;Not in this PR
Plumbing
idle_timeout=intomessages.create(stream=True)as a first-class parameter: it needs adecision about whether the window belongs on the client, on the call, or on the stream, and about how
it composes with
timeout=. Once that is settled the guard moves insideStream.__stream__and thepublic helper goes away.