Skip to content

fix: fail over and retry on a member's own timeout in Fallback and Retry - #5085

Merged
aaronvg merged 1 commit into
canaryfrom
aaron/fallback-timeout-failover
Oct 2, 2026
Merged

aaronvg merged 1 commit into
canaryfrom
aaron/fallback-timeout-failover

Conversation

@aaronvg

@aaronvg aaronvg commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Issue Reference

Found while migrating a production app from baml-py==0.221 to the imperative language: ai.clients.Fallback and ai.clients.Retry stop on a member timeout instead of failing over or retrying. The app has 16 fallback chains whose member timeouts were tuned so a hung model hands off fast; on 0.20 a hung primary fails the call.

Changes

A member's own baml.errors.Timeout is now a failed attempt, like a safe transport failure:

Wrapper Before After
Fallback (invoke) Timeout from member 1 ends the call moves to the next member
Fallback (stream, before first delta) Timeout ends the stream moves to the next member
Retry (invoke and stream, before first delta) never retried retried with the configured backoff
Any wrapper, nothing left to try the timeout unchanged: the original baml.errors.Timeout
After a visible text delta terminal unchanged: terminal
Caller deadline (baml.future.with_timeout around the call) Cancelled inside, Timeout outside unchanged

How:

  • clients.baml gets one safety gate, _may_try_again(cause: Retryable), where type Retryable = ai.errors.Failure | baml.errors.Timeout. Retry._attempt, Retry.invoke_stream, Fallback._try, Fallback.invoke_stream, and StreamRelay.recover all use it, so the rule exists once.
  • retry_if still judges typed failures only (Retry._wants_retry); a member timeout passes the gate and is retried. Backoff.delay_ms and the failed-leg LLMCall records accept a timeout (a timed-out leg reports provider "unknown").
  • Why retrying a Timeout is safe here: a caller's deadline never reaches these catches. with_timeout cancels the work it wraps, so members see baml.panics.Cancelled, which every wrapper already re-throws. A Timeout caught inside a wrapper is always the member's own (request_timeout_ms, time_to_first_token_timeout_ms, or a with_timeout the member applies itself).

This reverses the "preserve typed timeout without replay" choice from #4459. The type is still preserved; only the replay changes.

Legacy parity

The legacy timeout docs promise this behavior: "Timeouts let you fail fast and either retry or fallback to alternative clients" (fern/01-guide/04-baml-basics/timeouts.mdx), and "A timeout triggers the retry mechanism (if configured)" (fern/03-reference/baml/clients/timeouts.mdx). Measured against a local server that holds its response 3 s, with a 1 s member timeout:

Case baml-py 0.221 0.20 before this PR
fallback [timed-out, fast], invoke fast answers, 1.0 s Timeout, 1.0 s fast answers
fallback [timed-out, fast], stream (first token) fast answers, 1.0 s Timeout, 1.0 s fast answers
retry x3, every attempt times out 3 attempts, 3.2 s 1 attempt 3 attempts, then Timeout

Testing

  • Unit tests added/updated

  • Manual testing performed

  • BAML corpus (offline scripted clients):

    • invoke_error_normalization: the old "without replay" test now asserts the new contract (Retry makes 3 attempts; Fallback calls both members and surfaces the last member's Timeout; RoundRobin unchanged), plus "Fallback moves past a timed-out member to the next one" and "Retry retries a timed-out attempt until one answers" (both check the failed-leg records).
    • streaming_composite_clients: retry after a timeout while opening; fallback after a timeout before the first delta; the last member's Timeout surfaces; a timeout after a visible delta stays terminal.
  • End to end (tests/streaming_composite_clients.rs, wiremock, a real openai.ResponsesClient with request_timeout_ms = 200 and a held response): fallback_moves_past_member_request_timeout, fallback_stream_moves_past_member_request_timeout, retry_retries_member_request_timeout. All three fail without the clients.baml change.

  • cargo insta test --test-runner nextest --dnd -p baml_tests -p baml_cli -p baml_pack_host --all-features --unreferenced=reject: 1,595 tests pass, no snapshot changes. Run locally with BAML_AGENT_SKILL_CHECK=off; without it, 12 CLI-driven tests refuse to run under an agent.

  • Full corpus: 5,028 passed, 2 tolerated failures. clippy -p baml_tests --tests -D warnings and cargo fmt --check are clean. clients.baml is baml fmt-clean.

Additional Notes

Not in this PR (found in the same review, worth separate issues):

  • There is no idle (inter-event) timeout in 0.20. A stream that stalls after its first event is bounded only by the total request timeout.
  • aws.BedrockClient and google.GeminiClient expose no timeout fields at all.

🤖 Generated with Claude Code


Note

Medium Risk
Changes default reliability behavior for every Retry/Fallback chain (invoke and stream), reversing a recent no-replay-on-timeout choice; caller vs member timeout distinction must stay correct to avoid unsafe replays.

Overview
ai.clients.Retry and ai.clients.Fallback now treat a member’s own baml.errors.Timeout (e.g. request_timeout_ms) like a safely retryable transport failure instead of ending the call immediately.

A shared Retryable type and _may_try_again gate drive invoke, streaming (via StreamRelay), backoff, and failed-leg LLMCall recording. Retry._wants_retry still runs custom retry_if only on typed failures; member timeouts always retry when attempts remain. Caller deadlines stay Cancelled / outer Timeout; timeouts after the first streamed text delta stay terminal.

Tests flip the old “no replay on timeout” contract and add BAML scenarios plus wiremock e2e with real request_timeout_ms on openai.ResponsesClient.

Reviewed by Cursor Bugbot for commit 7a7749f. Bugbot is set up for automated code reviews on this repo. Configure here.

Summary by CodeRabbit

  • Bug Fixes
    • Retry and fallback behavior now handles timeout failures consistently for standard and streaming calls.
    • Retry can make another attempt after a timeout, and fallback can advance to another provider.
    • During streaming, a timeout can trigger recovery only before any text has been delivered; after text appears, the timeout is returned instead.
    • If all attempts fail, the final timeout is reported.

ai.clients.Fallback and ai.clients.Retry treated baml.errors.Timeout as terminal: a member that hit its own request_timeout_ms or time_to_first_token_timeout_ms ended the whole call, streaming or not, and Retry never retried it. Legacy clients failed over and retried on these timeouts, and the timeout docs promise that behavior.

A member's Timeout now passes the shared safety gate (_may_try_again) like a safe transport failure: Fallback moves to the next member and Retry tries again (retry_if keeps judging typed failures). When no member or attempt is left, the original Timeout propagates unchanged, so callers that match on the type keep working. A caller's deadline still cannot be retried: baml.future.with_timeout cancels the members, and every wrapper re-throws baml.panics.Cancelled. Streams still fail over only before the first text delta.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@vercel

vercel Bot commented Oct 2, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
developer-docs Ready Ready Preview Oct 2, 2026 1:13am UTC

Request Review

@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

⏭️ Performance benchmarks were skipped

Perf benchmarks (CodSpeed) are opt-in on pull requests — they no longer run on every push. They always run automatically after merge to canary/main.

To run them on this PR, do any of the following, then push a commit (or re-run CI):

  • Add RUN_CODSPEED=1 to the PR description, or
  • Include run-perf or /perf in the PR title or any commit message.

@coderabbitai

coderabbitai Bot commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Advanced

Run ID: 694dc8b0-c262-4349-a073-43d5c98ec743

📥 Commits

Reviewing files that changed from the base of the PR and between 049296b and 7a7749f.

📒 Files selected for processing (4)
  • baml_language/crates/baml_builtins2/baml_std/ai/ns_clients/clients.baml
  • baml_language/crates/baml_tests/baml_src/ns_invoke_error_normalization/invoke_error_normalization.baml
  • baml_language/crates/baml_tests/baml_src/ns_streaming_composite_clients/streaming_composite_clients.baml
  • baml_language/crates/baml_tests/tests/streaming_composite_clients.rs

Included review availability: This review used your included allowance. Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

Retry and Fallback now handle member timeouts alongside typed failures in invocation and streaming paths. Streaming recovery can replace a stream only before text is emitted. Tests cover scripted timeout scenarios and real HTTP request deadlines.

Changes

Composite client timeout recovery

Layer / File(s) Summary
Invocation retry and fallback
baml_language/crates/baml_builtins2/baml_std/ai/ns_clients/clients.baml, baml_language/crates/baml_tests/baml_src/ns_invoke_error_normalization/invoke_error_normalization.baml
A shared eligibility check allows timeouts through the Retry and Fallback invocation paths. Tests cover retry attempts, backup selection, and timeout exhaustion.
Streaming retry and fallback
baml_language/crates/baml_builtins2/baml_std/ai/ns_clients/clients.baml, baml_language/crates/baml_tests/baml_src/ns_streaming_composite_clients/streaming_composite_clients.baml
Streaming paths accept timeout causes. Tests cover timeouts before the first delta and propagation after a delta.
Real HTTP deadline coverage
baml_language/crates/baml_tests/tests/streaming_composite_clients.rs
Tests use delayed Responses API requests to verify fallback for streaming and non-streaming calls, and retry after a timeout.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Bug fix

Sequence Diagram(s)

sequenceDiagram
  participant CompositeClient
  participant ResponsesClient
  participant MockServer
  CompositeClient->>ResponsesClient: Send request with member timeout
  ResponsesClient->>MockServer: POST /responses
  MockServer-->>ResponsesClient: Delayed response
  ResponsesClient-->>CompositeClient: Timeout
  CompositeClient->>ResponsesClient: Retry or invoke backup member
  ResponsesClient->>MockServer: Send next request
  MockServer-->>ResponsesClient: Successful response
  ResponsesClient-->>CompositeClient: Return response
Loading

Merge Risk: ⚪ Minimal · up to 7a774

Retry and Fallback now recover from member timeouts while preserving caller cancellation and terminal timeouts after visible streaming output. No merge-blocking issue was established; merge after normal checks pass.

Security Architecture Review

Security architecture risk: 🔵 Low · up to 7a774

Recovery remains bounded by configured attempts and members, and streaming stops recovery once text is visible. The main residual risk is repeating provider work when a timed-out request may already have been accepted. No new privileged attack path was established.

Retained concerns

  • Low · reliability · inferred: Timeout recovery does not distinguish an unsent request from an accepted request whose outcome is unknown. Unlike typed failures, every member Timeout passes the safety gate, including for custom Client implementations. This can repeat provider work and weakens remote-effect containment; duplicate privileged effects are not established by the available evidence.
Security review details

Security Blast Radius

  • inferred — The changed behavior affects calls using Retry or Fallback whose selected member raises Timeout. Recovery can resend the input to the same configured client or another configured member; it does not itself select arbitrary destinations or grant additional credentials. Nested wrappers can compose their configured attempt budgets.

Security Findings and Attack Paths

  • inferred — A delayed or ambiguously completed provider request can now trigger additional work through timeout recovery. Available evidence supports this resource-containment concern, but does not demonstrate an attacker-controlled identity transition, cross-tenant access or duplicated privileged operation.

Trust Boundaries and Controls

  • observed — Caller cancellation is not a retryable error. with_timeout distinguishes its own deadline from caller cancellation, and the client error channel excludes the Cancelled panic rather than normalizing it into Timeout.

Resilience and Maintainability Implications

  • observed — The streaming replay boundary is visible text, preventing replacement after an emitted prefix. Supported function streaming rejects non-empty toolboxes, limiting the inspected replay path to text, metadata and completion events rather than tool execution.

Hardening Proposals

  • proposed — Make timeout replay semantics explicit for custom clients. Where a member can perform non-idempotent remote operations, consider an opt-out policy or an idempotency mechanism instead of treating elapsed time as proof that nothing committed.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 1 files. (3 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main change: member timeouts now trigger failover in Fallback and retries in Retry.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 12 functions across 1 files. (3 skipped: 3 unsupported.)

  • Fix all pre-merge checks with AI
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autopilot · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Autopilot is currently an internal CodeRabbit preview.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

A rabbit counts each timeout beat,
Then tries again on nimble feet.
If one stream shows its words in flight,
No switch can hide that partial sight.
A backup brings the answer home.

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Oct 2, 2026

Copy link
Copy Markdown

Binary size checks failed

❌ 2 violations · ✅ 1 passed

⚠️ Please fix the size gate issues or acknowledge them by updating baselines.

Artifact Platform File Gzip Gated on Baseline Delta Status
✅ baml-cli Linux 🔒 58.9 MB 25.0 MB file 73.1 MB -14.2 MB (-19.5%) OK
❌ packed-program Linux 🔒 37.4 MB 15.2 MB file 29.2 MB +8.2 MB (+28.2%) FAIL
❌ bridge_wasm WASM 25.7 MB 🔒 7.3 MB gzip 5.7 MB +1.6 MB (+28.6%) FAIL

🔒 = the size this artifact is GATED on (ceiling + delta). Binaries gate on file size (installed binary); WASM gates on gzip (download size). The other size is shown for information only.

Details & how to fix

Violations:

  • packed-program (Linux) file_bytes: 37.4 MB exceeds limit of 30.1 MB (exceeded by +7.3 MB, policy: max_file_bytes)
  • packed-program (Linux) file_delta_pct: +28.2% exceeds limit of 3.0% (exceeded by +25.2pp, policy: max_delta_pct)
  • bridge_wasm (WASM) gzip_bytes: 7.3 MB exceeds limit of 5.9 MB (exceeded by +1.4 MB, policy: max_gzip_bytes)
  • bridge_wasm (WASM) gzip_delta_pct: +28.6% exceeds limit of 3.0% (exceeded by +25.6pp, policy: max_delta_pct)

Add/update baselines:

.ci/size-gate/wasm32-unknown-unknown.toml:

[artifacts.bridge_wasm]
file_bytes = 25687842
gzip_bytes = 7281310

.ci/size-gate/x86_64-unknown-linux-gnu.toml:

[artifacts.packed-program]
file_bytes = 37369760
gzip_bytes = 15189916

Generated by cargo size-gate · workflow run

@aaronvg
aaronvg added this pull request to the merge queue Oct 2, 2026
Merged via the queue into canary with commit d651a0b Oct 2, 2026
98 checks passed
@aaronvg
aaronvg deleted the aaron/fallback-timeout-failover branch October 2, 2026 02:00
meefs pushed a commit to meefs/baml that referenced this pull request Oct 2, 2026
…5054)

Gives BAML v1 the four timeouts a v0 user relies on
(`request_timeout_ms`, `connect_timeout_ms`,
`time_to_first_token_timeout_ms`, `idle_timeout_ms`), on every LLM
client. This replaces the earlier contents of this PR with a new design.

## `baml.http`

```baml
let req = baml.http.Request.from_url("https://api.example.com/items")
    .set_method("POST")
    .set_headers({ "content-type": "application/json" })
    .set_body("{}")
    .set_timeout(baml.time.Duration.from_seconds(10))
    .set_connect_timeout(baml.time.Duration.from_seconds(2));
let resp = baml.http.send(req);
```

- `Request` gains `timeout` (end to end, connecting included) and
`connect_timeout`, plus a builder: `Request.from_url` and `set_method` /
`set_headers` / `set_body` / `set_timeout` / `set_connect_timeout`. The
setters mutate and return the request. `from_url` leaves the method
empty, and sending a request with no method throws `InvalidArgument`.
- The `timeout` argument stays on `fetch`, `send` and `send_sse`, and
overrides `Request.timeout`.
- Defaults: 5 minutes total and 10 seconds to connect when left `null`.
Canary was unbounded. A zero or negative `Duration` means no limit.
- `fetch_sse` is renamed `send_sse`, and its `first_event_timeout`
argument is removed.
- `http.Server` handlers (`handler`, `websocket`, `reject_websocket`)
take a new `baml.http.ServerRequest` instead of `Request`, so `Request`
is outgoing-only.
- `baml.errors.Timeout` is now `{ message, duration:
baml.time.Duration?, timeout_type: string }`. `duration_ms` is gone.
`timeout_type` is the name of the field or argument that set the limit:
`"timeout"` or `"connect_timeout"` from `baml.http`,
`"first_token_timeout"` or `"stream_token_timeout"` from an LLM client,
`"limit"` from `baml.future.with_timeout`, and `"timeout"` everywhere
else (process, net and WebSocket timeouts).

## LLM clients

```baml
let bounded = openai.ChatClient.new(
    model = "gpt-4o-mini",
    timeout = ai.LlmTimeoutOptions {
        timeout: baml.time.Duration.from_seconds(120),
        connect_timeout: baml.time.Duration.from_seconds(10),
        first_token_timeout: baml.time.Duration.from_seconds(30),
        stream_token_timeout: baml.time.Duration.from_seconds(10),
    },
);
// A bare Duration is the end-to-end total.
let simple = openai.ChatClient.new(model = "gpt-4o-mini", timeout = baml.time.Duration.from_seconds(120));
```

- All 13 HTTP clients take `timeout: baml.time.Duration |
ai.LlmTimeoutOptions | null = null`:
`openai.{Chat,Generic,Responses,Azure,OpenRouter,Ollama,Image}Client`,
`anthropic.Client`, `google.{Gemini,Vertex}Client`, `aws.BedrockClient`,
`typesafeai.Client`, `vercel.AiGatewayImageClient`. Seven of these had
no timeout before.
- `request_timeout_ms` and `time_to_first_token_timeout_ms` are removed
from the clients, and `request_timeout_ms` from `ai.wire.send_as` /
`send_as_with_wire`. There is no deprecation period. A client author now
sets the limits on the request itself.
- `timeout` and `connect_timeout` are enforced by the transport.
`first_token_timeout` and `stream_token_timeout` are enforced by
`ai.stream.TurnStream`.
- `first_token_timeout` starts when the attempt is dispatched (before
connecting) and ends on the first non-empty content delta.
`stream_token_timeout` is the gap between non-empty content deltas after
that. Content is a non-empty text, reasoning or tool-input delta, or a
complete tool-call or file part. Keep-alives, `message_start`,
usage-only frames and empty deltas do not count.
- Stream decoders yield a new `ai.stream.ContentSeen` event for content
that has no text delta. Code that matches exhaustively on
`ai.stream.Event` needs an arm for it.
- The two token timers only run while the client is waiting on the
provider, so a slow consumer is not charged.
- Clients that do not stream accept the two token timeouts and ignore
them.
- A timeout from any of the four deadlines is retried by `Retry` and
fails over in `Fallback` (already on canary from BoundaryML#5085; covered here for
the token timeouts).

## Behavior changes to know about

- Every `baml.http` request and every LLM call now has a 5 minute total
and a 10 second connect limit unless told otherwise, including a
streaming response body. Pass a zero `Duration` to opt out.
- The first-token timer now starts at dispatch, not when response
headers arrive, and ends on content, not on the first SSE event. A v0
value carried over unchanged also has to cover connecting, sending the
request and waiting for headers.
- Browser/Wasm transports still accept the limits and do not enforce
them.

## Testing

- New corpus namespace `ns_llm_timeouts` (17 tests): the first-token
timer ignores `message_start` and empty deltas and ends on tool input;
the stream-token timer fires between deltas, does not run before the
first content, and does not charge a slow consumer; zero means no limit;
non-streaming calls ignore the token timeouts; Ollama, OpenRouter, Azure
and Gemini honor `timeout`; `Fallback` advances on a first-token
timeout.
- New transport tests in `crates/baml_tests/tests/http.rs`:
`Request.timeout`, the argument override, `send_sse`, the connect
timeout (a peer that never answers the TLS handshake), zero and negative
limits, and a request with no method.
- Existing provider timeout tests are ported to the new argument and now
assert `timeout_type`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Added flexible timeout settings for HTTP requests and AI clients,
covering total request, connection, first-content, and between-content
deadlines. Unset total and connection limits default to five minutes and
ten seconds.
* Streaming deadlines account for non-text content, such as reasoning
and tool input, and report typed timeout errors.
* Separated incoming server requests from outgoing HTTP requests;
outgoing requests support configurable total and connection timeouts.
* **Breaking Changes**
* Replaced `fetch_sse` with `send_sse` and updated timeout configuration
and timeout error fields. Server handlers now use the distinct
incoming-request type.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>

This branch was successfully deployed

1 active deployment
Preview – developer-docs — 7a7749f5 Deployed Oct 2, 2026 by vercel[bot]
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