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:
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user