Skip to content

fix(runtime): LLM + embedding drivers set no HTTP timeout — hangs make the fallback chain unreachable - #20

Merged
Nideesh1 merged 2 commits into
mainfrom
fix/llm-driver-no-timeout
Jul 31, 2026
Merged

Nideesh1 merged 2 commits into
mainfrom
fix/llm-driver-no-timeout

Conversation

@Nideesh1

@Nideesh1 Nideesh1 commented Jul 31, 2026 •

Copy link
Copy Markdown

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. FallbackDriver only advances on Err. 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

Client File Timeout before this PR
openai drivers/openai.rs (2 builders) ❌ none
anthropic drivers/anthropic.rs ❌ none
gemini drivers/gemini.rs ❌ none
bedrock drivers/bedrock.rs ❌ none
vertex drivers/vertex.rs ❌ none (also no USER_AGENT)
embedding embedding.rs ❌ none (also no USER_AGENT)
web_search web_search.rs ✅ timeout(15s)
web_fetch web_fetch.rs ✅ timeout(config.timeout_secs)
a2a a2a.rs ✅ timeout(30s)
provider_health provider_health.rs ✅ connect_timeout + timeout
copilot (token exchange) drivers/copilot.rs ✅ timeout(10s)
copilot (device flow) drivers/copilot.rs ✅ timeout(30s)
tool_runner tool_runner.rs ✅ timeout(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.rs and embedding.rs construct their client with bare reqwest::Client::new() rather than Client::builder(), so a search for builder() sites did not surface them. Same bug, same consequence — vertex.rs in 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). CopilotDriver delegates completions to OpenAIDriver (make_inner_driver), so it inherits the fix.

vertex.rs was 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.rs was 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_loop already handles embed_one returning Err by 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_timeout and not timeout

timeout caps 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_timeout bounds 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.

⚠️ Not compiled locally — CI must verify

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 cargo run OOM-killed kube-apiserver. Running a workspace build there risks taking down the cluster.

What was verified without compiling:

  • cargo fmt --all runs clean and made no changes to the inserted code
  • builder / user_agent / connect_timeout / read_timeout counts 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/1
  • no reqwest::Client::new() remains anywhere under drivers/ or in embedding.rs
  • all three constants are pub const in crates/openfang-runtime/src/lib.rs, so crate:: paths resolve from both the drivers submodule and embedding
  • Cargo.lock pins reqwest 0.12.28; ClientBuilder::read_timeout exists on the non-wasm client since 0.12.5

CI is the source of truth for build/test/clippy on this PR.

🤖 Generated with Claude Code

scaling-workflow-systems and others added 2 commits July 31, 2026 20:08
… 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 Nideesh1 changed the title fix(runtime): LLM drivers set no HTTP timeout — hangs make the fallback chain unreachable fix(runtime): LLM + embedding drivers set no HTTP timeout — hangs make the fallback chain unreachable Jul 31, 2026
@Nideesh1
Nideesh1 marked this pull request as ready for review July 31, 2026 20:20
@Nideesh1
Nideesh1 merged commit 882811f into main Jul 31, 2026
10 of 11 checks passed
Nideesh1 pushed a commit that referenced this pull request Aug 1, 2026
The branch was cut from the pre-#20 worktree branch, so its merge base was
#18 and GitHub rendered the already-merged driver-timeout changes as part of
this PR's diff. Merging main advances the merge base so the PR shows only
what it actually changes: the remote-jobs collector brief.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants