fix(client): disable SSE read timeout — caught by personal Worldtree smoke

First manual smoke against personal Worldtree (10.250.50.152:8081)
produced httpx.ReadTimeout mid-stream after the worker_phase
BuildingPrompt event. Root cause: httpx's default 5s read timeout
killed the connection during mimir's thinking phase (LLM streaming
has multi-second idle gaps between SSE events).

Fix at the caller layer (where the AsyncClient is owned):
- cli._amain and tui.on_mount now construct AsyncClient with
  timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0).
  read=None disables the SSE-killing timeout; connect/write/pool
  keep modest timeouts so true network failures still surface
  promptly.

Defense in depth in sse_client.stream_turn:
- ERROR_ROUTING now also catches httpx.ReadTimeout (was just
  ReadError | RemoteProtocolError) and surfaces it as
  SseConnectionDropped, so if a caller misconfigures their client
  the failure is at least a named exception the presenters handle.

Contract amendments (in-place):
- Issue #1: new [compatibility] constraint documents the read=None
  recommendation; ERROR_ROUTING for stream_turn lists ReadTimeout
  alongside ReadError/RemoteProtocolError.
- Issues #3 + #4: AsyncClient construction step now spells out the
  timeout shape explicitly.

Smoke after fix: SSE stream consumed cleanly, agent responded,
[done] turn_id=88 model=qwen3.6-35-a3b duration_ms=2351. Stdout-only
(2>/dev/null) returned clean agent text + exit 0 — INV-002
stdout/stderr split holds end-to-end against real wire. Wire-compat
envelope (personal v0.16.2 vs ratatoskr's v0.19.0 pin) confirmed.

164/164 tests GREEN; ruff clean; all three drift checks clean.

Note: TUI mode not smoke-tested from this CC session (needs a TTY;
operator-side check via `source env.sh && uv run ratatoskr --new
--agent mimir`).
This commit is contained in:
2026-05-21 00:58:42 -07:00
parent 942e33898c
commit 61c3941ec3
7 changed files with 24 additions and 10 deletions
+2 -1
View File
@@ -89,6 +89,7 @@ The "reconnect, not resume-across-process" framing in the design-brief §8d MUST
- **[compatibility]** Module must work against the spec pin (`55101e909abcd2219833266b6f905c5bc956e0f0`, Worldtree v0.19.0). Spec bumps trigger an explicit re-record of the recorded-SSE snapshot fixtures (see `tests/snapshots/README.md`).
- **[performance]** Streaming MUST NOT buffer the full turn in memory — events are yielded as they arrive. The complete-response field on `Done` is what the server sends; the consumer does not re-aggregate from `text` events.
- **[compatibility]** Callers MUST configure their `httpx.AsyncClient` with a long-or-disabled `read` timeout for SSE flows. LLM streaming has multi-second idle gaps between events (especially during prompt-building, thinking phases, and long completions); httpx's default 5s read timeout would kill the connection mid-stream. The recommended shape is `httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0)` — disable read timeout, keep modest connect/write/pool timeouts so true network failures still surface promptly. As defense in depth, `stream_turn`'s ERROR_ROUTING also catches `httpx.ReadTimeout` and surfaces it as `SseConnectionDropped` — but the right place to configure is the caller-owned client.
- **[security]** `Authorization` header lives on the caller's `httpx.AsyncClient`. Module does not log the header, does not log full event bodies (they contain user message content). Logs are limited to `(turn_id, seq, type)` triples.
- **[style]** Async-native. No sync entry points. The consumer is `async def` + `async for`; presenters are async too.
@@ -108,7 +109,7 @@ ERROR_ROUTING:
local_handling: re-raise as SseConnectFailed(status=resp.status_code, body=resp.read()[:1024]) — server returned non-2xx before stream started (e.g., 404 session_not_found)
flow_control: abort
state_recovery: none (no events yielded yet)
httpx.ReadError | httpx.RemoteProtocolError:
httpx.ReadError | httpx.RemoteProtocolError | httpx.ReadTimeout:
local_handling: re-raise as SseConnectionDropped(last_seen_sse_id=<last yielded event's sse_id or None>)
flow_control: abort
state_recovery: none (caller may reconnect_turn)
+1 -1
View File
@@ -323,7 +323,7 @@ ERROR_ROUTING:
flow_control: abort
state_recovery: none
STEPS:
1. [setup, flexibility=prescriptive] OPEN httpx.AsyncClient(base_url=args.server_url, headers={"Authorization": f"Bearer {args.api_key}"}) via async-with
1. [setup, flexibility=prescriptive] OPEN httpx.AsyncClient(base_url=args.server_url, headers={"Authorization": f"Bearer {args.api_key}"}, timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0)) via async-with — read=None disables the SSE-killing 5s default (issue #1 [compatibility] constraint)
2. [branch, flexibility=prescriptive] IF args.new:
TRY: info = await sessions.create_session(client, args.agent_id)
ON AgentNotFound as exc:
+1 -1
View File
@@ -212,7 +212,7 @@ ERROR_ROUTING:
state_recovery: none
STEPS:
1. [setup, flexibility=prescriptive] Validate PRE-001
2. [sequential, flexibility=prescriptive] Open AsyncClient: self.client = httpx.AsyncClient(base_url=args.server_url, headers={"Authorization": f"Bearer {args.api_key}"})
2. [sequential, flexibility=prescriptive] Open AsyncClient: self.client = httpx.AsyncClient(base_url=args.server_url, headers={"Authorization": f"Bearer {args.api_key}"}, timeout=httpx.Timeout(connect=10.0, read=None, write=10.0, pool=10.0)) — read=None disables the SSE-killing 5s default (issue #1 [compatibility] constraint)
3. [branch, flexibility=prescriptive] IF args.new:
TRY: info = await create_session(self.client, args.agent_id)
ON AgentNotFound | SessionApiFailed | httpx.ConnectError | httpx.ReadTimeout | httpx.TransportError: handle per ERROR_ROUTING (append + exit)