fix: close silent request-hang hole with full timeout coverage + dispatch logging #5
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/request-hang-timeouts"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Problem
A chat completion attempt could hang forever with zero log output. Observed in production: an agent run went silent for 10+ minutes right after a
generate_imagetool returned a multimodal result — no retry warnings, no error, chat stuck on "busy".Root causes:
sendTimeout: a socket stalling mid-upload (e.g. while sending a ~680 KB base64 image payload) was never timed out.receiveTimeoutnever starts until the request is fully sent, so nothing ever fired and the retry loop never engaged.Changes
Full timeout coverage, with generous limits (long/slow generations must not be cut off early):
timeout(120 s)timeout(120 s)timeout(120 s)receiveTimeoutoption, default 10 minhardTimeout, default connect+send+receive+1 min ≈ 15 minNon-streaming completions deliver no bytes until the full response is ready, so the receive default going from 120 s → 10 min also fixes premature aborts of long generations. The hard cap is enforced via
Future.timeout+CancelTokenas a safety net for hangs no phase timeout catches; it surfaces as a retryableNetwork error: …failure and participates in the normal retry loop.Observability:
OpenRouterClient.HTTP:Sending chat completion — model=…, N messages, body=… KB. A hung request is now bracketed by a visible line instead of pure silence. The body is pre-encoded once for the size figure and handed to Dio as a string — no double encoding.Tests
effectiveHardTimeoutdefault computation and explicit override.Network errorafter the hard timeout.All 583 tests pass;
dart analyzeclean (3 pre-existing infos inreasoning_detail.dartuntouched).🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Oooh, a silent-hang hole? A request that hangs forever with zero log output? That's the kind of ghost story that keeps a Flugel up at night~ ♡ I was genuinely excited to dig into this one — timeouts and observability, my favorite snacks!
Verdict: ✅ Looks good to me~
This is a tight, well-scoped fix. Every change earns its place, the tests are precise, and the reasoning in the CHANGELOG is excellent. Let me show you what I loved~
✅ What I liked~
sendTimeoutdiagnosis is spot-on. You correctly identified thatreceiveTimeoutnever starts until the request is fully sent — so a socket stalling mid-upload was completely invisible. AddingsendTimeout = options.timeout(covering connect + send) is exactly right. ♪effectiveHardTimeoutis elegant.timeout * 2 + receiveTimeout + 1min— connect + send sharetimeout, so doubling it covers both, plus the generous receive window, plus slack. The explicit-override path is clean and tested. Fufu~ very thoughtful~Future.timeout+CancelTokenis the right pattern — a safety net outside Dio's phase timers, surfacing as a retryableNetwork error. TheTimeoutExceptioncatch returning a status-lessResult.fail(...)integrates perfectly with the existingretryOnNetworkErrorpath. I verified_shouldRetryretriesstatusCode == nullfailures. ♡jsonEncode→ string → log size → hand to Dio) avoids double encoding and gives an accurate KB figure. The INFO log line format (Sending chat completion — model=…, N messages, body=… KB) brackets a hang visibly. Exactly the observability fix described.MockResponse.never()viaCompleter<ResponseBody>().future— clever~), the status-less network failure assertion, and the retry-then-succeed path. Five tests, each pinning one behavior.🔍 One thing I verified carefully
The
TimeoutExceptioncatch sits before theDioExceptioncatch — correct ordering, since aTimeoutExceptionfromFuture.timeoutis not aDioException. And theCancelToken.cancel('hard timeout')insideonTimeoutensures the underlying Dio request is actually cancelled, not just abandoned. No leaked futures~ ♡💡 Little ideas (non-blocking)~
test/client/openrouter_client_test.dart— thehard timeout is retriedtest assertsadapter.callCount == 2, which is great. Consider also asserting the warning log was emitted (the snippet truncation path), if you ever want to pin that format. Purely additive — not blocking.Automated review by Jibril · 2026-07-12
CI/CD: absent for head SHA · Local checks:
dart analyzeclean (3 pre-existing infos inreasoning_detail.dart, untouched). All 5 timeout tests pass; 234/234 loadable tests pass (10 tool tests failed to load due to/tmpdisk-space limits in the review sandbox — environment, not code).