Skip to content

Commit a7df37e

Browse files
pmbrullclaude
andcommitted
fix: don't apply request timeout to SSE streams across all SDKs
Long agent runs (>2 min) were being cut mid-stream because each SDK's HTTP client applied its standard request timeout to SSE response bodies. The failure surfaces as a decode/connection error, masking the timeout as the real cause. - Rust CLI: separate streaming reqwest client with no timeout - Python: separate httpx client with read=None for post_stream (sync + async) - TypeScript: dropped AbortController deadline from postStream - Java: only connectTimeout was set; added Javadoc warnings to lock that in Default timeout for non-streaming calls bumped 120 -> 900s across all four. Also adds 'ai-sdk chat --debug [PATH]' to the Rust CLI: writes SSE event traces (each event, message, and the underlying error + unparsed buffer when a stream dies) to a file. Defaults to ~/.ai-sdk/chat-debug.log. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 7e15192 commit a7df37e

19 files changed

Lines changed: 592 additions & 43 deletions

File tree

cli/CLAUDE.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,16 @@ The `chat` command launches an interactive terminal interface:
6363
ai-sdk chat # Opens agent selector
6464
ai-sdk chat AgentName # Opens chat with specific agent
6565
ai-sdk chat AgentName -c <conv-id> # Resume conversation
66+
ai-sdk chat --debug # Log SSE events to ~/.ai-sdk/chat-debug.log
67+
ai-sdk chat --debug /tmp/sse.log # Log SSE events to a custom path
6668
```
6769

70+
The `--debug` flag truncates the log on session start and appends events from
71+
every stream within the session — useful for diagnosing issues like
72+
`Network error: error decoding response body` mid-stream. The log captures
73+
each SSE event, message-level details, and the underlying error and unparsed
74+
buffer when the stream is cut short.
75+
6876
### TUI Features
6977

7078
- **Agent selection menu**: `/agents` or start without agent name

cli/src/client.rs

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -621,9 +621,15 @@ impl MemorySearchResults {
621621
}
622622

623623
/// AI SDK API client.
624+
///
625+
/// Uses two underlying reqwest clients:
626+
/// - `client`: standard HTTP requests, bounded by `config.timeout`.
627+
/// - `streaming_client`: SSE endpoints, with no request timeout — agent runs
628+
/// can take many minutes and the stream itself indicates progress.
624629
#[derive(Clone)]
625630
pub struct AISdkClient {
626631
client: Client,
632+
streaming_client: Client,
627633
base_url: String,
628634
token: String,
629635
}
@@ -636,11 +642,18 @@ impl AISdkClient {
636642
.build()
637643
.map_err(CliError::from_reqwest)?;
638644

645+
// No `.timeout(...)` → reqwest waits indefinitely for the response
646+
// body, which is what we want for SSE: the server may take minutes
647+
// to finish a multi-step agent run, and the stream itself signals
648+
// liveness via thinking/message events.
649+
let streaming_client = Client::builder().build().map_err(CliError::from_reqwest)?;
650+
639651
// Normalize base URL (remove trailing slash)
640652
let base_url = config.host.trim_end_matches('/').to_string();
641653

642654
Ok(Self {
643655
client,
656+
streaming_client,
644657
base_url,
645658
token: config.token.clone(),
646659
})
@@ -861,7 +874,7 @@ impl AISdkClient {
861874
});
862875

863876
let response = self
864-
.client
877+
.streaming_client
865878
.post(&url)
866879
.header("Authorization", self.auth_header())
867880
.header("Content-Type", "application/json")
@@ -896,7 +909,7 @@ impl AISdkClient {
896909
};
897910

898911
let response = self
899-
.client
912+
.streaming_client
900913
.post(self.agents_url(&format!("/name/{encoded_name}/stream")))
901914
.header("Authorization", self.auth_header())
902915
.header("Content-Type", "application/json")

cli/src/commands/chat.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
//! Interactive chat command with TUI interface.
22
3+
use crate::config;
34
use crate::error::CliResult;
45
use crate::tui::run_tui;
6+
use std::path::PathBuf;
57

68
/// Run interactive chat session with an agent.
79
///
@@ -10,17 +12,37 @@ use crate::tui::run_tui;
1012
/// The TUI calls `stream_default_agent` for each message.
1113
/// - `agent_name = Some(name)` → use the named dynamic agent (existing behaviour).
1214
/// - both `None` / `false` → show the agent-selection menu on start.
15+
///
16+
/// The `debug` argument carries the raw clap value:
17+
/// - `None`: debug disabled
18+
/// - `Some("")`: enabled, write to default path (`~/.ai-sdk/chat-debug.log`)
19+
/// - `Some(path)`: enabled, write to caller-provided path
1320
pub async fn run_chat(
1421
profile: &str,
1522
agent_name: Option<&str>,
1623
use_default: bool,
1724
conversation_id: Option<&str>,
25+
debug: Option<String>,
1826
) -> CliResult<()> {
27+
let debug_log_path: Option<PathBuf> = match debug {
28+
None => None,
29+
Some(s) if s.is_empty() => Some(config::config_dir()?.join("chat-debug.log")),
30+
Some(s) => Some(PathBuf::from(s)),
31+
};
32+
33+
if let Some(path) = debug_log_path.as_ref() {
34+
eprintln!(
35+
"[ai-sdk] SSE debug events will be written to {}",
36+
path.display()
37+
);
38+
}
39+
1940
run_tui(
2041
profile,
2142
agent_name,
2243
use_default,
2344
conversation_id.map(String::from),
45+
debug_log_path,
2446
)
2547
.await
2648
}

cli/src/config.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ impl Default for ProfileConfig {
4444
}
4545

4646
fn default_timeout() -> u64 {
47-
120
47+
900
4848
}
4949

5050
/// Credentials structure (stored separately).

cli/src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,11 @@ enum Commands {
131131
/// Continue an existing conversation
132132
#[arg(short = 'c', long = "conversation")]
133133
conversation_id: Option<String>,
134+
135+
/// Write SSE debug events to a file. Pass --debug alone to use the
136+
/// default path (~/.ai-sdk/chat-debug.log) or --debug PATH to override.
137+
#[arg(long, value_name = "PATH", num_args = 0..=1, default_missing_value = "")]
138+
debug: Option<String>,
134139
},
135140
}
136141

@@ -473,12 +478,14 @@ async fn main() {
473478
agent,
474479
use_default,
475480
conversation_id,
481+
debug,
476482
} => {
477483
commands::chat::run_chat(
478484
&cli.profile,
479485
agent.as_deref(),
480486
use_default,
481487
conversation_id.as_deref(),
488+
debug,
482489
)
483490
.await
484491
}

cli/src/streaming.rs

Lines changed: 120 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ use crate::error::{CliError, CliResult};
1010
use futures_util::StreamExt;
1111
use reqwest::Response;
1212
use serde::Deserialize;
13+
use std::io::Write;
14+
use std::path::Path;
1315

1416
/// Sender types matching ChatMessage.
1517
#[derive(Debug, Clone, PartialEq, Deserialize)]
@@ -167,15 +169,52 @@ pub async fn process_stream<F>(response: Response, handler: F) -> CliResult<Opti
167169
where
168170
F: FnMut(ChatMessage),
169171
{
170-
process_stream_with_debug(response, handler, false).await
172+
process_stream_inner(response, handler, None).await
171173
}
172174

173-
/// Process a streaming response with optional debug output.
175+
/// Process a streaming response with debug events optionally written to stderr.
174176
pub async fn process_stream_with_debug<F>(
175177
response: Response,
176-
mut handler: F,
178+
handler: F,
177179
debug: bool,
178180
) -> CliResult<Option<String>>
181+
where
182+
F: FnMut(ChatMessage),
183+
{
184+
let writer: Option<Box<dyn Write + Send>> = if debug {
185+
Some(Box::new(std::io::stderr()))
186+
} else {
187+
None
188+
};
189+
process_stream_inner(response, handler, writer).await
190+
}
191+
192+
/// Process a streaming response and append debug events to a file.
193+
///
194+
/// The file is opened in append mode so multiple streams in the same session
195+
/// accumulate into one log. Callers that want a fresh log per session should
196+
/// truncate the file once before invoking this.
197+
pub async fn process_stream_with_debug_file<F>(
198+
response: Response,
199+
handler: F,
200+
debug_log_path: &Path,
201+
) -> CliResult<Option<String>>
202+
where
203+
F: FnMut(ChatMessage),
204+
{
205+
let file = std::fs::OpenOptions::new()
206+
.create(true)
207+
.append(true)
208+
.open(debug_log_path)?;
209+
process_stream_inner(response, handler, Some(Box::new(file))).await
210+
}
211+
212+
/// Shared implementation for stream processing.
213+
async fn process_stream_inner<F>(
214+
response: Response,
215+
mut handler: F,
216+
mut debug_writer: Option<Box<dyn Write + Send>>,
217+
) -> CliResult<Option<String>>
179218
where
180219
F: FnMut(ChatMessage),
181220
{
@@ -186,7 +225,28 @@ where
186225
let mut message_count = 0;
187226

188227
while let Some(chunk) = stream.next().await {
189-
let chunk = chunk.map_err(|e| CliError::NetworkError(e.to_string()))?;
228+
let chunk = match chunk {
229+
Ok(c) => c,
230+
Err(e) => {
231+
// Surface where the stream died — the most useful detail when
232+
// debugging "error decoding response body" failures.
233+
if let Some(w) = debug_writer.as_mut() {
234+
let _ = writeln!(
235+
w,
236+
"[DEBUG] Stream error after {event_count} events, {message_count} messages: {e}"
237+
);
238+
if !buffer.is_empty() {
239+
let _ = writeln!(
240+
w,
241+
"[DEBUG] Unparsed buffer at error ({} bytes): {buffer:?}",
242+
buffer.len()
243+
);
244+
}
245+
let _ = w.flush();
246+
}
247+
return Err(CliError::NetworkError(e.to_string()));
248+
}
249+
};
190250
let text = String::from_utf8_lossy(&chunk);
191251
buffer.push_str(&text);
192252

@@ -197,8 +257,8 @@ where
197257

198258
if let Some((event_type, payload)) = parse_event(&event_str) {
199259
event_count += 1;
200-
if debug {
201-
eprintln!("[DEBUG] Event #{event_count}: {event_type:?}");
260+
if let Some(w) = debug_writer.as_mut() {
261+
let _ = writeln!(w, "[DEBUG] Event #{event_count}: {event_type:?}");
202262
}
203263

204264
// Capture conversation ID from any event
@@ -211,8 +271,9 @@ where
211271
if let Some(data) = payload.data {
212272
if let Some(message) = data.message {
213273
message_count += 1;
214-
if debug {
215-
eprintln!(
274+
if let Some(w) = debug_writer.as_mut() {
275+
let _ = writeln!(
276+
w,
216277
"[DEBUG] Message #{}: sender={:?}, content_blocks={}",
217278
message_count,
218279
message.sender,
@@ -231,8 +292,8 @@ where
231292
if !buffer.trim().is_empty() {
232293
if let Some((event_type, payload)) = parse_event(&buffer) {
233294
event_count += 1;
234-
if debug {
235-
eprintln!("[DEBUG] Final Event #{event_count}: {event_type:?}");
295+
if let Some(w) = debug_writer.as_mut() {
296+
let _ = writeln!(w, "[DEBUG] Final Event #{event_count}: {event_type:?}");
236297
}
237298

238299
if let Some(id) = &payload.conversation_id {
@@ -242,8 +303,9 @@ where
242303
if let Some(data) = payload.data {
243304
if let Some(message) = data.message {
244305
message_count += 1;
245-
if debug {
246-
eprintln!(
306+
if let Some(w) = debug_writer.as_mut() {
307+
let _ = writeln!(
308+
w,
247309
"[DEBUG] Final Message #{}: sender={:?}",
248310
message_count, message.sender
249311
);
@@ -255,8 +317,12 @@ where
255317
}
256318
}
257319

258-
if debug {
259-
eprintln!("[DEBUG] Stream complete: {event_count} events, {message_count} messages");
320+
if let Some(w) = debug_writer.as_mut() {
321+
let _ = writeln!(
322+
w,
323+
"[DEBUG] Stream complete: {event_count} events, {message_count} messages"
324+
);
325+
let _ = w.flush();
260326
}
261327

262328
Ok(conversation_id)
@@ -352,4 +418,44 @@ data: {"conversationId":"conv-1","data":{"message":{"sender":"assistant","conten
352418
let tools = message.tools_used();
353419
assert_eq!(tools, vec!["searchMetadata"]);
354420
}
421+
422+
#[tokio::test]
423+
async fn test_process_stream_with_debug_file_writes_events() {
424+
use wiremock::matchers::{method, path};
425+
use wiremock::{Mock, MockServer, ResponseTemplate};
426+
427+
let server = MockServer::start().await;
428+
let body = "event: stream-start\ndata: {\"streamId\":\"s1\",\"conversationId\":\"c1\",\"sequence\":0}\n\nevent: message\ndata: {\"conversationId\":\"c1\",\"data\":{\"message\":{\"sender\":\"assistant\",\"content\":[{\"textMessage\":\"hi\"}],\"conversationId\":\"c1\"}}}\n\nevent: stream-completed\ndata: {\"message\":\"done\",\"type\":\"completed\"}\n\n";
429+
430+
Mock::given(method("GET"))
431+
.and(path("/sse"))
432+
.respond_with(
433+
ResponseTemplate::new(200)
434+
.insert_header("content-type", "text/event-stream")
435+
.set_body_string(body),
436+
)
437+
.mount(&server)
438+
.await;
439+
440+
let response = reqwest::get(format!("{}/sse", server.uri())).await.unwrap();
441+
442+
let log_path =
443+
std::env::temp_dir().join(format!("ai-sdk-test-debug-{}.log", uuid::Uuid::new_v4()));
444+
// Ensure clean slate
445+
let _ = std::fs::remove_file(&log_path);
446+
447+
let conv_id = process_stream_with_debug_file(response, |_msg| {}, &log_path)
448+
.await
449+
.unwrap();
450+
assert_eq!(conv_id.as_deref(), Some("c1"));
451+
452+
let contents = std::fs::read_to_string(&log_path).unwrap();
453+
assert!(contents.contains("Event #1: StreamStart"));
454+
assert!(contents.contains("Event #2: Message"));
455+
assert!(contents.contains("Message #1: sender=Assistant"));
456+
assert!(contents.contains("Event #3: StreamCompleted"));
457+
assert!(contents.contains("Stream complete: 3 events, 1 messages"));
458+
459+
let _ = std::fs::remove_file(&log_path);
460+
}
355461
}

java/src/main/java/io/openmetadata/ai/AISdk.java

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,11 @@
3535
*/
3636
public class AISdk implements AutoCloseable {
3737

38-
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(120);
38+
// Generous default — agent runs can take many minutes. Note this maps to
39+
// HttpClient.Builder.connectTimeout(...) only (TCP connect), so SSE bodies
40+
// are never bounded by it. Per-request HttpRequest.timeout(...) is
41+
// intentionally NOT set on streaming requests; see AISdkHttpClient.
42+
private static final Duration DEFAULT_TIMEOUT = Duration.ofSeconds(900);
3943
private static final int DEFAULT_MAX_RETRIES = 3;
4044
private static final Duration DEFAULT_RETRY_DELAY = Duration.ofSeconds(1);
4145
private static final String MEMORIES_BASE_PATH = "/api/v1/contextCenter/memories";
@@ -172,7 +176,13 @@ public Builder token(String token) {
172176
/**
173177
* Sets the request timeout.
174178
*
175-
* <p>Default: 120 seconds
179+
* <p>This value is applied as the {@link
180+
* java.net.http.HttpClient.Builder#connectTimeout(Duration) TCP connect timeout} on the
181+
* underlying {@link java.net.http.HttpClient}. It does NOT bound the time spent reading a
182+
* response body: SSE streams from long-running agent runs may continue for many minutes, and
183+
* the stream's own events (thinking/message/tool) signal liveness.
184+
*
185+
* <p>Default: 900 seconds
176186
*
177187
* @param timeout the timeout duration
178188
* @return this builder

0 commit comments

Comments
 (0)