Skip to content

feat(providers): configurable timeouts, graceful rate-limit retry, non-answer detection - #167

Merged
antonbabenko merged 3 commits into
masterfrom
fix/provider-timeouts-ratelimit-empty
Jul 27, 2026
Merged

feat(providers): configurable timeouts, graceful rate-limit retry, non-answer detection#167
antonbabenko merged 3 commits into
masterfrom
fix/provider-timeouts-ratelimit-empty

Conversation

@antonbabenko

Copy link
Copy Markdown
Owner

Why

Three failure modes showed up in one /ask-all fan-out, and none of them were reachable from config:

{"tool":"ask-one","provider":"grok","model":"grok-4.5","ms":718,"isError":true,"errorKind":"rate-limit"}
{"tool":"ask-one","provider":"openrouter:qwen3-7-max","ms":180006,"isError":true,"errorKind":"timeout"}
{"tool":"ask-one","provider":"openrouter:kimi-k3","ms":180002,"isError":true,"errorKind":"timeout"}
{"tool":"ask-one","provider":"openrouter:glm-5-2","ms":180007,"isError":true,"errorKind":"timeout"}
{"tool":"ask-one","provider":"codex","ms":600016,"isError":true,"errorKind":"auth"}

Separately, Gemini returned in 8s with "I will begin by finding the repository directory..." and nothing else — and that counted as a successful answer.

What changed

Timeouts are configurable for every provider

models.<id>.timeout was the only key that governed an MCP-path call. The composition root passed no timeout to codex/gemini/grok, so req.timeoutMs was structurally undefined and each provider sat on its hardcoded default. providers.<name>.timeout did not exist in the schema, and providers.openrouter.defaults.timeout was read only by the standalone bridge.

New providers.defaults.timeout covers all four at once; providers.<name>.timeout overrides one, with OpenRouter reusing the defaults block it already owns rather than gaining a second key.

req.timeoutMs > models.<id>.timeout > providers.<name>.timeout
  > providers.defaults.timeout > adapter built-in

The ladder resolves in resolveProviders (the config SSOT), so the composition root just reads the resolved value. Read at startup, so a change needs an MCP restart.

The timeout did not cover the response body

Both HTTP bridges cleared the abort timer in finally before await res.text(), leaving the body read unbounded. This is why deepseek-v4-pro succeeded at 250,346 ms in the same fan-out where three siblings died at exactly 180,00x ms, and how a parse failure was able to burn 2,295,013 ms (38 min).

The controller now stays armed through the body, and a body-read abort classifies as timeout rather than network. The Grok Files API upload had no abort signal at all; it is now bounded by GROK_UPLOAD_TIMEOUT_MS (120s, 0 disables).

Rate limits are handled instead of dropped

rate-limit was marked retryable: true but callProvider retried network only (exact string match), and Retry-After was read nowhere in the repo. Both bridges now parse it onto the error, toErrorResult forwards it, and callProvider retries once for network | rate-limit | empty, waiting the hint (default 2s, clamped to 30s so a hostile value cannot stall a fan-out).

timeout stays non-retryable on purpose: the call may already have burned tokens, and a slow-but-good answer should not be discarded.

A provider stub is an error, not an answer

server/gemini/index.js accepted any non-empty stdout on exit 0 as a full answer, so an 8s preamble reached the caller as a real opinion. Inside consensus, parseReview turned it into verdict: null, silently blocking convergence with no diagnostic. (parseOpinion / validateOpinion would have caught it, but they are referenced only by tests — wellFormed: false never reaches a caller.)

A clean exit shorter than GEMINI_MIN_ANSWER_CHARS (default 80, 0 disables) now fails as errorKind: "empty" and is retried once. The floor applies only on the normal clean-exit path — after a soft timeout, a short recovered answer still beats a hard timeout, so the drain branch is unchanged.

A character count is a proxy for "announced intent instead of answering"; the upgrade path is a structured-output check once parseOpinion is wired in.

Two smaller fixes

  • A SIGKILL'd codex now reports timeout from the kill flag instead of a stderr substring match. A killed codex usually writes nothing, so it classified as unknown — or as auth, since "author" contains "auth" — and an unknown can never trip the consensus circuit breaker.
  • The five ask-* slash commands capped at 180000–300000 ms, below the new server ceiling, so /ask-gpt gave up at 180s while the codex provider was still allowed to run to 600s.

Tests

33 new, 633 total, 0 failing:

  • timeout precedence per provider (codex / gemini / grok / openrouter) and through the config layer, including that providers.defaults never leaks in as a provider entry
  • the retry ladder: rate-limit and empty retry once, timeout / auth / parse / config do not, a second failure is returned as-is
  • Retry-After parsing in both forms, plus the case that Date.parse("-5") succeeds as a year — which would have clamped a garbage header to 0 and meant "retry immediately"
  • body-read timeouts for both bridges against a mock server that sends headers and then stalls mid-body
  • the Gemini floor, its =0 escape hatch, and that output above the floor still succeeds

The existing bridge fixtures print 11-char sentinels to assert plumbing rather than answer length, so startBridge disables the floor by default and the floor tests opt back in.

Verification

  • npm run check — 633 pass, 0 fail, typecheck clean
  • npm run sync:check — persona, host artifacts, command fallbacks all up to date
  • npm run prepack --workspace=server/mcp — bundle builds
  • config.default.json and a real user config both resolve through validateConfig with the expected per-provider ceilings

Not verified here: a live /ask-all after an MCP restart. Constructor args are not hot-reloaded, so that needs /reload-plugins first.

Docs

README, SETUP, TECHNICAL, CLAUDE.md, AGENTS.md, plus config.schema.json and config.default.json. Generated host artifacts regenerated via scripts/sync-hosts.js.

…n-answer detection

Three failure modes showed up in one ask-all fan-out and none of them were
reachable from config.

Timeouts. models.<id>.timeout was the only key that governed an MCP-path call.
The composition root passed no timeout to codex/gemini/grok, so req.timeoutMs
was structurally undefined and each provider sat on its hardcoded default;
providers.<name>.timeout did not exist in the schema, and
providers.openrouter.defaults.timeout was read only by the standalone bridge.

Adds providers.defaults.timeout (one knob for every provider) plus
providers.<name>.timeout, with OpenRouter reusing the defaults block it already
owns instead of a second key. The ladder resolves in resolveProviders, so the
composition root just reads the resolved value:

  req.timeoutMs > models.<id>.timeout > providers.<name>.timeout
    > providers.defaults.timeout > adapter built-in

Response body was not covered by the timeout. Both HTTP bridges cleared the
abort timer in `finally` before `await res.text()`, leaving the body read
unbounded. That is why deepseek-v4-pro returned at 250s in the same fan-out
where three siblings died at exactly 180,00x ms, and how a parse failure was
able to burn 38 minutes. The controller now stays armed through the body, and a
body-read abort classifies as timeout rather than network. The Grok Files API
upload had no abort signal at all; it is now bounded by GROK_UPLOAD_TIMEOUT_MS.

Rate limits were classified retryable but never retried, and Retry-After was
read nowhere. Both bridges now parse it onto the error, toErrorResult forwards
it, and callProvider retries once for network | rate-limit | empty, waiting the
hint (default 2s, clamped to 30s). timeout stays non-retryable: the call may
already have burned tokens, and a slow-but-good answer should not be discarded.

Gemini counted any non-empty stdout on exit 0 as a full answer, so an 8-second
agy run printing only "I will begin by finding the repository directory..."
reached the caller as a real opinion - and inside consensus parseReview turned
it into verdict:null, silently blocking convergence with no diagnostic. A clean
exit shorter than GEMINI_MIN_ANSWER_CHARS (default 80, 0 disables) now fails as
errorKind "empty" and is retried once. The floor applies only on the normal
clean-exit path; after a soft timeout a short recovered answer still beats a
hard timeout, so the drain branch is unchanged.

Also: a SIGKILL'd codex now reports timeout from the kill flag instead of a
stderr substring match. A killed codex usually writes nothing, so it classified
as unknown - or as auth, since "author" contains "auth" - and an unknown can
never trip the consensus circuit breaker.

The five ask-* slash commands capped at 180000-300000ms, below the new server
ceiling, so /ask-gpt gave up while the provider was still allowed to run.

33 new tests: timeout precedence per provider and through the config layer, the
retry ladder and Retry-After clamp, body-read timeouts against a stalling mock
server, and the Gemini floor plus its escape hatch.
Only the final result was logged, so a rate-limit or empty stub that
succeeded on retry left no trace in debug.jsonl - and that log is exactly
how provider health gets diagnosed. A retried call now emits two
provider_result rows.

Found in multi-model review of #167.
Three findings from the multi-model review of #167.

The timeout knob missed half the surface. /ask-grok, /ask-gemini and
/ask-openrouter call the STANDALONE bridges, not the unified server, and
those read their timeout from the call argument only - so they stayed
pinned to 180s/300s/180s no matter what providers.defaults.timeout said.
Each bridge now resolves the configured ceiling itself: configuredTimeout()
in the Grok and Gemini bridges, and providers.openrouter.timeout added to
the OpenRouter bridge's pick chain.

A non-abort body failure on an OK status was misclassified. A mid-stream
socket drop ("TypeError: terminated") was swallowed, leaving an empty body
that then failed JSON.parse as `parse` - non-retryable, so the new network
retry never ran. It now surfaces as `network`. On an error status the body
is only diagnostic, so the status is kept and the body reported empty.

The answer floor now applies on the drain path too. Exempting it made
validity timing-dependent: the same stub failed when agy was fast and
passed when it was slow. Three reviewers flagged this independently. A
sub-floor stub recovered after a soft timeout falls through to the hard
timeout, which is the honest result.

Also: parseRetryAfterMs drops an overflowing delta-seconds instead of
forwarding Infinity, which would serialize to null and break the
finite-ms contract.

9 new tests (644 total).
@antonbabenko

Copy link
Copy Markdown
Owner Author

Multi-model review (/ask-all, 7 delegates)

Ran the panel against this branch. 3 of 7 produced a usable review - and the failures were themselves reproductions of the bugs being fixed:

Delegate Result
Codex (GPT) Review - 3 HIGH, 2 MEDIUM
Gemini (gemini-3.6-flash-high) Review - 2 critical, 3 recommendations
OpenRouter / deepseek-v4-pro Review - 1 HIGH. Returned at 772,426 ms on a 180s ceiling
Grok (grok-4.5) 143s of tool narration, no review - the non-answer class this PR is about
OpenRouter / qwen3-7-max timeout at 180,012 ms
OpenRouter / kimi-k3 timeout at 180,003 ms
OpenRouter / glm-5-2 timeout at 180,002 ms

Three models dying at exactly 180,00x ms while a fourth ran 12.9 minutes past the same ceiling is the original incident, live, on the installed build.

Acted on

Standalone bridges ignored the configured timeout (Codex, HIGH). /ask-grok, /ask-gemini and /ask-openrouter call the standalone bridges, not the unified server. Only the unified server's composition root was wired, so those three stayed pinned to their built-ins - the knob missed half the surface it claims to cover. Each bridge now resolves the ceiling itself.

Non-abort body failure misclassified (Codex, HIGH). A mid-stream socket drop on an OK status was swallowed, leaving an empty body that failed JSON.parse as the non-retryable parse - so the retry never ran. Now network. Pre-existing, but the new retry made it matter.

Answer floor exempted the drain path (Codex + Gemini + deepseek, independently). It made validity timing-dependent: the same stub failed when agy was fast and passed when it was slow. My original rationale - a short recovered answer beats a hard timeout - doesn't survive that framing. Floor now applies on both paths.

Retried calls hid the first attempt (Gemini, MEDIUM). Only the final result was logged, so a rate-limit that succeeded on retry vanished from debug.jsonl - the log used to diagnose exactly this. Two rows now.

Overflowing Retry-After (Codex, MEDIUM). A 400-digit header became Infinity, serializing to null. Dropped instead.

Declined, with reasons

Lower the 80-char floor to 10-15 (Gemini). The preamble that motivated this is 51 characters - a 15-char floor would not have caught it. GEMINI_MIN_ANSWER_CHARS covers usage that skews short.

The config change breaks fan-out (Gemini). config.providers is only read by keyed lookup (core/registry.js:117, server/mcp/index.js:291); nothing enumerates the map, and the registry filters from the constructed provider list. Emitting entries with enabled: true is inert. Gemini's sandbox denied filesystem access, so it couldn't check. Codex, which could, independently confirmed it is selection-neutral.

The letter-guard rejects numeric dates (Gemini). RFC 9110 defines Retry-After as delta-seconds or IMF-fixdate, and IMF-fixdate always contains letters. A bare ISO date is out of spec, and falls back to the 2s default rather than misparsing as year 1.5.

Retrying empty can double-charge (Codex + Gemini). Accepted as a tradeoff: one retry against an 8-second stub, versus surfacing the stub as a real opinion.

npm run check: 644 pass, 0 fail. Fixes in b93b629 and 5e26011.

@antonbabenko
antonbabenko merged commit eccd3d0 into master Jul 27, 2026
1 check passed
@antonbabenko
antonbabenko deleted the fix/provider-timeouts-ratelimit-empty branch July 27, 2026 14:15
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.

1 participant