Repository navigation
fix(runtime): LLM + embedding drivers set no HTTP timeout — hangs make the fallback chain unreachable - #20
Merged
Merged
Conversation
… unreachable All four LLM driver HTTP clients (openai, anthropic, gemini, bedrock) were built with `reqwest::Client::builder()` and never set a timeout. reqwest applies NO timeout by default, so a provider that accepts the TCP connection and then never answers hangs the agent forever. The delay is not the worst part. A hang never returns `Err`, so `FallbackDriver` never fires — the fallback chain is unreachable precisely when it is needed. The mechanism that exists to survive a bad provider is disabled by the exact failure mode it was built for. Converting a hang into an error is what makes fallback work at all. Observed in production: an agent sat 15 minutes on a single request and made zero tool calls, while the same model answered a direct probe in seconds. Every other HTTP client in the repo already sets timeouts — web_search (15s), web_fetch (configurable), a2a (30s), provider_health (connect + overall), copilot token exchange, tool_runner (30s/15s). Only the LLM drivers, the ones that gate every agent turn, did not. Adds two constants in openfang-runtime: LLM_CONNECT_TIMEOUT_SECS = 15 (TCP + TLS only; unreachable => fail over fast) LLM_READ_TIMEOUT_SECS = 300 (stall detector) `read_timeout` is deliberate, not `timeout`. `timeout` caps total request duration and would kill long legitimate generations and long streams. `read_timeout` bounds the gap between successive bytes, so a slow-but-alive connection is fine while a dead one fails. 300s is kept under DEFAULT_AGENT_TURN_TIMEOUT_SECS (600) so the driver, not the turn supervisor, gives up first — the error stays attributable to the provider and a fallback still has time to run. CopilotDriver delegates completions to OpenAIDriver, so it inherits the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up to the previous commit on this branch, which patched the five LLM driver clients built with `Client::builder()`. These two were missed because they construct the client with bare `reqwest::Client::new()` instead, so a search for `builder()` did not surface them — same bug, same consequence. `vertex.rs` is the more serious of the two: it is a full chat-completion driver, so with no read timeout a Vertex endpoint that accepts the connection and never answers hangs the agent forever. A hang never returns `Err`, so `FallbackDriver` never fires — the fallback chain is unreachable precisely when it is needed. `vertex.rs` was additionally missing `.user_agent(crate::USER_AGENT)`, which every other driver sets; the constant's doc comment notes that some providers reject requests that arrive without one. Added here too. `embedding.rs` gets a deliberately shorter read timeout via a new `EMBEDDING_READ_TIMEOUT_SECS` (60s). An embedding call has no generation phase and normally returns in well under a second, and failing it is cheap: `agent_loop` already logs "falling back to text search" and continues with keyword recall when `embed_one` returns `Err`. Waiting the full 300s would stall the turn before doing the same work the fallback would have done immediately. The two `Client::builder()` sites in `copilot.rs` were audited and left alone: they already set a total `.timeout()` (10s token exchange, 30s device flow) and are OAuth-only clients, not chat paths. Not compiled or tested locally (no build on this host); CI must verify. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Nideesh1
marked this pull request as ready for review
July 31, 2026 20:20
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Root cause
The LLM driver HTTP clients never set a timeout. reqwest applies no timeout by default, so a provider that accepts the TCP connection and then never answers hangs the agent forever.
Observed in production: an agent sat 15 minutes on a single request and made zero tool calls, while the same model answered a direct probe in seconds.
Why this is worse than a slow request
A hang never returns
Err.FallbackDriveronly advances onErr. So the fallback chain is unreachable precisely when it is needed — the mechanism built to survive a bad provider is disabled by the exact failure mode it exists for.Converting a hang into an error is what makes fallback work at all. That is the real fix here; the timeout is just the means.
Audit: every non-LLM client already had timeouts
drivers/openai.rs(2 builders)drivers/anthropic.rsdrivers/gemini.rsdrivers/bedrock.rsdrivers/vertex.rsUSER_AGENT)embedding.rsUSER_AGENT)web_search.rstimeout(15s)web_fetch.rstimeout(config.timeout_secs)a2a.rstimeout(30s)provider_health.rsconnect_timeout+timeoutdrivers/copilot.rstimeout(10s)drivers/copilot.rstimeout(30s)tool_runner.rstimeout(30s)/timeout(15s)The only clients without a timeout were the ones that gate every single agent turn.
The last two were nearly missed
vertex.rsandembedding.rsconstruct their client with barereqwest::Client::new()rather thanClient::builder(), so a search forbuilder()sites did not surface them. Same bug, same consequence —vertex.rsin particular is a full chat-completion driver with the identical agent-hang exposure, so leaving it for a follow-up would have shipped a fix that still left the hang open. Both are folded in here.The fix
Three constants in
openfang-runtime/src/lib.rs:LLM_CONNECT_TIMEOUT_SECS = 15— TCP + TLS establishment only. An unreachable provider should fail over immediately rather than stalling a turn.LLM_READ_TIMEOUT_SECS = 300— stall detector for chat completions.EMBEDDING_READ_TIMEOUT_SECS = 60— stall detector for embeddings (new; see below).Applied to all 7 client constructions (openai ×2, anthropic, gemini, bedrock, vertex, embedding).
CopilotDriverdelegates completions toOpenAIDriver(make_inner_driver), so it inherits the fix.vertex.rswas additionally the only driver not sending.user_agent(crate::USER_AGENT). That constant's doc comment records that some providers (Moonshot, Qwen) reject requests arriving without one — a separate latent bug, fixed in the same edit.embedding.rswas likewise missing it and now sets it too.Why embeddings get a shorter read timeout
300s is calibrated for a chat completion, which has a generation phase and can legitimately go quiet for a while mid-stream. An embedding call has no generation phase, returns a fixed-size vector, and normally completes in well under a second — a 60s silence already means the provider is not coming back.
Failing fast is also cheap here, unlike a chat completion. Embeddings only upgrade memory recall to vector similarity search.
agent_loopalready handlesembed_onereturningErrby logging"Embedding recall failed, falling back to text search"and continuing with keyword recall. Waiting the full 300s would stall the turn for five minutes before doing the exact work the existing fallback would have done immediately.Why
read_timeoutand nottimeouttimeoutcaps total request duration. Setting it would kill long legitimate generations and long streams — a reasoning model or a large streamed response would be terminated mid-flight for doing nothing wrong.read_timeoutbounds the gap between successive bytes. A connection that keeps producing output can run as long as it likes; a connection that goes silent fails. That is exactly the distinction between "slow" and "dead", and only the second one should be an error.300s is deliberately under
DEFAULT_AGENT_TURN_TIMEOUT_SECS(600) so the driver gives up first, not the turn supervisor. That keeps the error attributable to the provider and leaves the fallback chain enough remaining budget to actually run.This was not built or tested locally. The only available host is a live Kubernetes control-plane node (15 GB RAM, no swap) where a previous
cargorun OOM-killedkube-apiserver. Running a workspace build there risks taking down the cluster.What was verified without compiling:
cargo fmt --allruns clean and made no changes to the inserted codeuser_agent/connect_timeout/read_timeoutcounts match exactly per file — openai 2/2/2/2, anthropic 1/1/1/1, gemini 1/1/1/1, bedrock 1/1/1/1, vertex 1/1/1/1, embedding 1/1/1/1reqwest::Client::new()remains anywhere underdrivers/or inembedding.rspub constincrates/openfang-runtime/src/lib.rs, socrate::paths resolve from both thedriverssubmodule andembeddingCargo.lockpins reqwest 0.12.28;ClientBuilder::read_timeoutexists on the non-wasm client since 0.12.5CI is the source of truth for build/test/clippy on this PR.
🤖 Generated with Claude Code