Skip to content

fix(providers): stop killing streaming responses at the total request timeout - #10620

Merged
filipkujawa merged 6 commits into
mainfrom
fix/stream-timeout-session-death
Aug 4, 2026
Merged

fix(providers): stop killing streaming responses at the total request timeout#10620
filipkujawa merged 6 commits into
mainfrom
fix/stream-timeout-session-death

Conversation

@filipkujawa

@filipkujawa filipkujawa commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Problem

Model turns streaming longer than 10 minutes die with Stream decode error: error decoding response body.
In headless goose run the reply loop treats this as terminal and silently ends the session.
Long turns are routine with high thinking effort, large single-file generations, and slow local models.

Root cause: ApiClient sets reqwest's client-level .timeout(600s), a total request deadline that includes the streamed body, so it caps turn duration instead of connection health.
Reproduced on Terminal-Bench 2.1: all 5 regex-chess trials died at exactly 600-601s.

Fix

The client uses connect_timeout(30s) + read_timeout(timeout); the total deadline is applied per request only to non-streaming requests, and provider SSE call sites are marked .streaming(true).
Streaming requests still bound everything before the response body (connect, request upload, first byte) with the configured timeout; only the streamed body itself is exempt.
Providers with their own reqwest clients (gcpvertexai, kimicode, gemini_oauth, OAuth device flow, ChatGPT Codex, Bedrock Mantle) get the same split.
Error-body reads on paths that lost the total deadline are bounded (30s), including 200-status JSON error payloads on the Tetrate and Snowflake streaming endpoints.
The Anthropic factories now honor timeout_seconds / ANTHROPIC_TIMEOUT, matching openai.rs.

Request type Before After
Streaming, healthy but >600s killed mid-stream completes
Streaming, silent >600s killed killed (read timeout)
Non-streaming 600s total deadline unchanged

Scope: this PR is transport-only.
An earlier revision also retried mid-stream network errors in the agent loop; that was split out so agent-layer retry has a single owner in #10533.

Validation

  • Unit tests against a mock slow-chunk server: healthy stream outlives the total timeout, stalled stream still times out, non-streaming deadline unchanged.
  • Terminal-Bench 2.1 A/B on the 4 affected tasks (k=5): stream deaths 11/20 → 0/20; 23 turns >600s survived (max 2440s); regex-chess 0/5 → 3/5.
    The A/B ran with the since-removed agent retry included; it fired on 1 of the 20 trials, and the other recoveries came from the transport fix alone.

Tradeoff: worst-case hung-stream detection stays 600s (of silence).

Fixes #9413

@filipkujawa
filipkujawa marked this pull request as draft July 21, 2026 22:58
@michaelneale

Copy link
Copy Markdown
Collaborator

I think this will be necessary - wonder if it has to be as cross cutting as this (maybe it does). Looks tricky, but yes please.

@filipkujawa
filipkujawa marked this pull request as ready for review July 22, 2026 16:17
@filipkujawa
filipkujawa force-pushed the fix/stream-timeout-session-death branch from 333dfb8 to 425dfee Compare July 23, 2026 16:51

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 425dfeea74

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread crates/goose/src/agents/agent.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4bccd34ec9

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread crates/goose/src/agents/agent.rs Outdated

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4ed3ad0ac0

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread crates/goose-providers/src/api_client.rs
@filipkujawa
filipkujawa force-pushed the fix/stream-timeout-session-death branch from 4ed3ad0 to d7ac23d Compare July 27, 2026 17:47
@filipkujawa
filipkujawa requested a review from michaelneale July 27, 2026 18:32
@filipkujawa
filipkujawa force-pushed the fix/stream-timeout-session-death branch from d7ac23d to 2cc9cdb Compare July 27, 2026 18:49
@filipkujawa
filipkujawa requested review from DOsinga and jbg July 27, 2026 19:02
@alexhancock alexhancock self-assigned this Jul 30, 2026
@filipkujawa
filipkujawa marked this pull request as draft July 30, 2026 22:12
@filipkujawa
filipkujawa force-pushed the fix/stream-timeout-session-death branch from 2cc9cdb to a3e5fbe Compare July 30, 2026 22:39
@filipkujawa
filipkujawa marked this pull request as ready for review July 30, 2026 23:13

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a3e5fbe526

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread crates/goose-providers/src/api_client.rs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: bedfd26bac

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

let client = Client::builder()
.timeout(Duration::from_secs(DEFAULT_PROVIDER_TIMEOUT_SECS))
.connect_timeout(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS))
.read_timeout(Duration::from_secs(DEFAULT_PROVIDER_TIMEOUT_SECS))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep Vertex send deadline before opening stream

For GCP Vertex streaming calls, this client now has only connect/read timeouts, while send_request_with_retry later awaits .send() directly. With a large prompt or image payload and a proxy/provider that accepts the connection but stops reading the request body, no read is in progress so read_timeout will not fire, and the turn can hang before any SSE body exists; before this diff the client-level total timeout bounded that phase. Please wrap the .send() phase in the configured deadline and only drop the total deadline after the response stream is opened.

Useful? React with 👍 / 👎.

let client = Client::builder()
.timeout(StdDuration::from_secs(DEFAULT_PROVIDER_TIMEOUT_SECS))
.connect_timeout(StdDuration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS))
.read_timeout(StdDuration::from_secs(DEFAULT_PROVIDER_TIMEOUT_SECS))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep Kimi sends bounded before streaming

Kimi streaming requests use this client in post() and then await .send() without adding a per-request timeout. After replacing the previous total client timeout with only read_timeout, a large request body can hang indefinitely if the connection is accepted but the peer stops reading, because the stream body has not started and no read timeout applies. Please keep a total deadline around the request upload/time-to-headers phase and remove it only for the opened response stream.

Useful? React with 👍 / 👎.

reqwest::Client::builder()
.timeout(Duration::from_secs(DEFAULT_PROVIDER_TIMEOUT_SECS))
.connect_timeout(Duration::from_secs(DEFAULT_CONNECT_TIMEOUT_SECS))
.read_timeout(Duration::from_secs(DEFAULT_PROVIDER_TIMEOUT_SECS))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep Gemini OAuth sends bounded before streaming

Gemini OAuth streaming posts use this shared client and later call .send() directly, so replacing the total timeout with only read_timeout leaves the pre-response phase unbounded. If a large payload is sent through a proxy/provider that accepts the connection but stops reading the body, the turn can stall before any SSE response exists; the old client-level timeout capped that. Please apply the configured deadline to .send() and only exempt the response body from the total timeout.

Useful? React with 👍 / 👎.

@filipkujawa
filipkujawa force-pushed the fix/stream-timeout-session-death branch from bedfd26 to e3032f0 Compare July 31, 2026 00:31

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: e3032f0a92

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".


/// Streaming requests carry no total-request deadline, so error-body reads
/// must be bounded or a server that drips a non-2xx body stalls retries forever.
const ERROR_BODY_READ_TIMEOUT: Duration = Duration::from_secs(30);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use configured timeout for error bodies

In this revision the new ERROR_BODY_READ_TIMEOUT hard-codes a 30s status-body read for every handle_status caller. When a streaming request gets non-2xx headers and the user configured a shorter provider timeout (for example OPENAI_TIMEOUT=5 or timeout_seconds: 5), retries and failure still wait up to 30s here before returning, so the configured timeout no longer bounds the non-generation error path that was moved out from under reqwest's total deadline. Thread the client's configured timeout into this read instead of using a global cap.

Useful? React with 👍 / 👎.

… timeout

reqwest's client-level timeout is a total-request deadline that includes the
streamed body, so any model turn longer than 600s died mid-stream with a
decode error. Apply the total deadline per request and exempt requests marked
.streaming(true); streams are bounded by read_timeout (reset per chunk) and
connect_timeout instead.

Providers with hand-rolled clients (GCP Vertex, Kimi, Gemini OAuth, device
flow, ChatGPT Codex, Bedrock Mantle) get the same connect/read bounds, plus
per-request total deadlines on their non-streaming calls. Error-body reads on
paths that serve streaming are bounded separately since those requests no
longer carry a total deadline.
@filipkujawa
filipkujawa force-pushed the fix/stream-timeout-session-death branch from e3032f0 to 748e6e7 Compare August 3, 2026 20:19

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5ba84157d6

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +280 to +283
pub async fn handle_response(
response: Response,
timeout: Duration,
) -> Result<Value, ProviderError> {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update remaining response-helper callers

Changing handle_response to require a Duration also changes the re-exported handle_response_openai_compat, but several unchanged callers still invoke it with one argument, including azure_foundry.rs:276, openai_compatible.rs:188, and openai.rs:580. cargo check -p goose-providers --lib now fails with E0061, so the crate cannot build until those paths pass a timeout or the legacy alias preserves the old signature.

Useful? React with 👍 / 👎.

Comment on lines +241 to +243
pub fn timeout(&self) -> Duration {
self.timeout
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove the duplicate timeout accessor

This newly added timeout method duplicates the existing timeout method later in the same impl ApiClient block, and cargo check -p goose-providers --lib fails with E0592 before downstream crates can build. Remove one of the definitions before merging.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 585460f563

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +696 to +700
ApiClient::with_timeout_and_tls(
format!("http://{}", addr),
AuthMethod::NoAuth,
Duration::from_millis(timeout_ms),
None,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make timeout tests bypass proxies

In environments with HTTP_PROXY/HTTPS_PROXY set and NO_PROXY not covering 127.0.0.1, this helper sends the mock-server requests through the proxy instead of the local TcpListener. I ran cargo test -p goose-providers api_client::tests in this runner and the four new timeout tests failed by receiving proxy responses (403 from envoy or empty bodies); setting NO_PROXY=127.0.0.1,localhost makes the local-server path work. Please disable proxies for this test client or otherwise make the loopback tests hermetic.

Useful? React with 👍 / 👎.

Comment on lines +251 to +253
pub async fn read_error_body(response: Response, timeout: Duration) -> Option<String> {
tokio::time::timeout(timeout, response.text())
.await

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use one deadline for streaming status handling

When a streaming request gets non-2xx headers near the configured deadline, send_bounded has already allowed up to the provider timeout for upload/time-to-headers; this line then starts another full timeout for the error body, so a setting like OPENAI_TIMEOUT=5 can spend nearly 10 seconds per attempt before retrying or failing. Fresh evidence in this revision is that the hard-coded error-body cap was replaced with this second configured timeout instead of sharing a single deadline across send plus status handling.

Useful? React with 👍 / 👎.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1c9c3242cc

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".


async fn send_bounded(&self, request: reqwest::RequestBuilder) -> Result<Response> {
if self.streaming {
Ok(crate::http_status::send_bounded(request, self.client.timeout).await?)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve retryable errors from streaming sends

When a streaming request times out or fails before headers, http_status::send_bounded returns a ProviderError::NetworkError, but this ? wraps that typed error in anyhow::Error. The streaming providers then call response_post(...).await? inside ProviderError retry closures, and From<anyhow::Error> does not recover an embedded ProviderError, so the retryable network error becomes ExecutionError("Network error: ...") and skips the provider retry budget. Please avoid routing this path through anyhow or preserve embedded ProviderError during conversion.

Useful? React with 👍 / 👎.

@filipkujawa
filipkujawa added this pull request to the merge queue Aug 4, 2026
Merged via the queue into main with commit 7e431ac Aug 4, 2026
26 checks passed
@filipkujawa
filipkujawa deleted the fix/stream-timeout-session-death branch August 4, 2026 22:22
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.

Provider timeout ignored? Set to 15min, but stops at 10min

4 participants