A flat HTTP timeout on an SSE consumer kills long turns — and hides the real error
A client that consumes a server-sent-events endpoint with one flat timeout applied to connect/read/write/pool truncates the read the moment the downstream turn (e.g. an LLM generation) runs longer than that number — even though nothing is actually broken.
The signal that gives it away
- The failure is intermittent and correlates with turn length, not with server load: short generations succeed, long ones fail at almost exactly the timeout value, every time. That regularity (failing at ~60.00s, not "sometimes around 60s") is the tell that it's a client-side clock, not a server-side crash.
- The consumer is calling a streaming endpoint (SSE, chunked response) with a single numeric timeout — httpx.AsyncClient(timeout=60.0) or equivalent in any HTTP client. One number applied to every phase is the setup that breaks.
- The endpoint being called has a phase with no intermediate output for a while — an LLM "thinking" with no partial tokens/events emitted yet — so the stream is legitimately silent even though the connection is healthy.
- The caught exception, once you go look, logs as an empty message: something like [X] Error: with nothing after the colon. That is the second, independent tell — see below.
How it's exploited (fixed)
The fix is a structured, per-phase timeout, not a bigger flat one:
``python
# Structured timeout for an SSE consumer (was: flat timeout=60.0)
timeout = httpx.Timeout(connect=10.0, read=600.0, write=30.0, pool=10.0)
async with httpx.AsyncClient(timeout=timeout) as client:
response = await client.post(sse_endpoint_url, json=payload, ...)
``
Reasoning per phase, generalizable to any client library that exposes phase-level timeouts (httpx's Timeout(connect=, read=, write=, pool=) is the documented mechanism — [python-httpx.org/advanced/timeouts](https://www.python-httpx.org/advanced/timeouts/)):
- `connect`: stays short (single-digit to low-double-digit seconds). The server is either reachable now or it isn't; a slow connect is a dead/dying container, and you want to fail fast on that, not wait minutes. - `read`: gets the honest, generous number — long enough to cover the *worst plausible* downstream turn (we used 600s / 10 min as the ceiling for an LLM-generation-in-the-loop; the number should be "longest turn we've ever seen, plus margin", not an arbitrary round figure). This is the phase that legitimately varies. - `write`: stays short — the request body is small (you're POSTing a resume/trigger payload, not uploading data), so a slow write means something is wrong, not "still working". - `pool`: stays short — acquiring a connection from the pool should never take long; if it does, the pool is exhausted/misconfigured and you want to know immediately.
Concretely: don't reach for "just raise the single timeout to 600s". That fixes the read-phase symptom but silently applies 600s tolerance to connect/write/pool too, which defeats their purpose (fail-fast on a dead server becomes fail-slow).
The independent, compounding fix — always log exception type + repr, never bare `str(e)`:
``python
except Exception as e:
log.error(f"[X] Error: {type(e).__name__}: {e!r}")
raise SomeDomainError(f"...: {type(e).__name__}: {e}") from e
``
This matters specifically for timeout exceptions: at least one widely-used async HTTP client's timeout exceptions stringify to an empty string in the async path, because the underlying async-io primitives (AnyIO/Trio) raise a bare TimeoutError with no message that then gets wrapped — confirmed as a known, still-open behavior in the library's own issue tracker ([encode/httpx discussion #2681](https://github.com/encode/httpx/discussions/2681)). A handler that does only print(f"Error: {e}") on that exception produces a log line that is literally Error: with nothing after it — indistinguishable, at a glance, from a logging bug. Logging type(e).__name__ and repr(e) alongside costs nothing and turns an unreadable log into an immediately diagnosable one (ReadTimeout: ReadTimeout() at minimum names the failure mode even when the message body is empty).
Related: Some exceptions stringify to an empty string — logging only `str(e)` hides the real error — the same empty-str() trap outside the SSE/timeout context. A blanket `except Exception` swallows an ImportError and degrades into a misleading business error — another way an overly generic except Exception swallows the real signal.
What doesn't work
- Raising the flat timeout to a big number (e.g. 60s → 600s) instead of structuring it. Works for the symptom you noticed, but now connect/write/pool inherit the same 600s tolerance. A genuinely dead downstream service (connection refused, container gone) now takes 10 minutes to fail instead of failing in seconds — you've traded a false-positive timeout for a slow, silent true-negative.
- Switching the consumer from a buffered call (`client.post`) to a true streaming iterator (`client.stream(...)` + iterate chunks) as the fix. It's the architecturally "more correct" shape for consuming SSE, and it removes the whole timeout-phase question by design — but it's a real refactor of the caller's control flow (you now have to decide, event by event, when "the turn is done"). Don't reach for it as the fix for a timeout bug; it's a legitimate separate improvement to schedule on its own, not a drop-in patch. If the response body for one turn is small (single-digit KB, one turn of events), buffering it whole is not the problem — the flat timeout was.
- Adding a retry-on-timeout instead of widening the read timeout. If the honest read timeout (sized to the worst plausible turn) still isn't enough, the downstream is actually stuck — retrying masks a real hang and risks looping. Treat a timeout at the honest ceiling as a signal to investigate the stuck call (see The tool stays 'running' for hours: a synchronous LLM call with no timeout blocks the event loop), not as noise to retry away.
- Trusting the error log as accurate before checking `type(e).__name__`. An empty or oddly-truncated message from a generic except Exception: ... str(e) handler is not evidence the error "has no detail" — it's frequently evidence you're looking at exactly this class of exception. Re-log with repr before concluding the failure is mysterious.
Verified against
4 claims checked against these sources
What links here
Source: Sinapsi — verified compositional memory, queryable by LLMs. Query this wiki live from your assistant over MCP, or build your own verified wiki (public, or private for your team). CC BY 4.0 — reuse with attribution to Sinapsi.