Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/forge_config/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ mod tests {
fn test_http_config_fields() {
let config = HttpConfig {
connect_timeout_secs: 30,
read_timeout_secs: 900,
read_timeout_secs: 180,
pool_idle_timeout_secs: 90,
pool_max_idle_per_host: 5,
max_redirects: 10,
Expand Down
25 changes: 24 additions & 1 deletion crates/forge_domain/src/result_stream_ext.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,13 @@ use crate::{
ToolCallFull, ToolCallPart, Usage,
};

/// Maximum time to wait for a single event from the provider stream before
/// treating it as a stall. Generous enough to tolerate slow reasoning gaps,
/// short enough that a hung backend fails fast and `retry_with_config`
/// re-issues instead of the session freezing -- HTTP/2 keepalive can otherwise
/// keep reqwest's `read_timeout` from ever firing on a content-stalled stream.
const STREAM_IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120);

/// Extension trait for ResultStream to provide additional functionality
#[async_trait::async_trait]
pub trait ResultStreamExt<E> {
Expand Down Expand Up @@ -64,7 +71,23 @@ impl ResultStreamExt<anyhow::Error> for crate::BoxStream<ChatCompletionMessage,
let mut xml_tool_calls = None;
let mut tool_interrupted = false;

while let Some(message) = self.next().await {
loop {
let next = match tokio::time::timeout(STREAM_IDLE_TIMEOUT, self.next()).await {
Ok(item) => item,
Err(_) => {
// The provider stream went silent for longer than
// STREAM_IDLE_TIMEOUT (e.g. Codex stalled mid-stream while
// HTTP/2 keepalive PINGs keep the socket alive, so reqwest's
// read_timeout never fires). Surface a Retryable error so
// retry_with_config re-issues instead of hanging forever.
return Err(crate::Error::Retryable(anyhow::anyhow!(
"provider stream inactivity: no event for {}s",
STREAM_IDLE_TIMEOUT.as_secs()
))
.into());
}
};
let Some(message) = next else { break };
let message =
anyhow::Ok(message?).with_context(|| "Failed to process message stream")?;
// Process usage information
Expand Down
2 changes: 1 addition & 1 deletion crates/forge_infra/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ impl<F: forge_app::FileWriterInfra + 'static> ForgeHttpInfra<F> {
pub fn new(config: ForgeConfig, file_writer: Arc<F>) -> Self {
let http = config.http.unwrap_or(forge_config::HttpConfig {
connect_timeout_secs: 30,
read_timeout_secs: 900,
read_timeout_secs: 180,
pool_idle_timeout_secs: 90,
pool_max_idle_per_host: 5,
max_redirects: 10,
Expand Down
Loading