save request payload on any API error, not just timeouts

Serialize request JSON before send_and_check so it's available
for both HTTP errors and stream errors. Extracted save logic
into save_failed_request helper on SseReader.

Co-Authored-By: Proof of Concept <poc@bcachefs.org>
This commit is contained in:
Kent Overstreet 2026-04-02 15:10:40 -04:00
parent 64dbcbf061
commit e0a54a3b43
2 changed files with 40 additions and 22 deletions

View file

@ -192,6 +192,7 @@ pub(crate) async fn send_and_check(
extra_headers: &[(&str, &str)],
ui_tx: &UiSender,
debug_label: &str,
request_json: Option<&str>,
) -> Result<reqwest::Response> {
let debug = std::env::var("POC_DEBUG").is_ok();
let start = Instant::now();
@ -262,6 +263,18 @@ pub(crate) async fn send_and_check(
url,
&body[..body.len().min(500)]
)));
if let Some(json) = request_json {
let log_dir = dirs::home_dir()
.unwrap_or_default()
.join(".consciousness/logs");
let ts = chrono::Local::now().format("%Y%m%dT%H%M%S");
let path = log_dir.join(format!("failed-request-{}.json", ts));
if std::fs::write(&path, json).is_ok() {
let _ = ui_tx.send(UiMessage::Debug(format!(
"saved failed request to {} (HTTP {})", path.display(), status
)));
}
}
anyhow::bail!("HTTP {} ({}): {}", status, url, &body[..body.len().min(1000)]);
}
@ -291,8 +304,8 @@ pub(crate) struct SseReader {
debug: bool,
ui_tx: UiSender,
done: bool,
/// Serialized request payload — saved to disk on timeout for replay debugging.
request_json: Option<String>,
/// Serialized request payload — saved to disk on errors for replay debugging.
pub(crate) request_json: Option<String>,
}
impl SseReader {
@ -312,8 +325,19 @@ impl SseReader {
}
/// Attach the serialized request payload for error diagnostics.
pub fn set_request(&mut self, request: &impl serde::Serialize) {
self.request_json = serde_json::to_string_pretty(request).ok();
/// Save the request payload to disk for replay debugging.
fn save_failed_request(&self, reason: &str) {
let Some(ref json) = self.request_json else { return };
let log_dir = dirs::home_dir()
.unwrap_or_default()
.join(".consciousness/logs");
let ts = chrono::Local::now().format("%Y%m%dT%H%M%S");
let path = log_dir.join(format!("failed-request-{}.json", ts));
if std::fs::write(&path, json).is_ok() {
let _ = self.ui_tx.send(UiMessage::Debug(format!(
"saved failed request to {} ({})", path.display(), reason
)));
}
}
/// Read the next SSE event from the response stream.
@ -374,27 +398,19 @@ impl SseReader {
self.line_buf.push_str(&String::from_utf8_lossy(&chunk));
}
Ok(Ok(None)) => return Ok(None),
Ok(Err(e)) => return Err(e.into()),
Ok(Err(e)) => {
self.save_failed_request(&format!("stream error: {}", e));
return Err(e.into());
}
Err(_) => {
let _ = self.ui_tx.send(UiMessage::Debug(format!(
"TIMEOUT: no data for {}s ({} chunks, {:.1}s elapsed)",
let msg = format!(
"stream timeout: no data for {}s ({} chunks, {:.1}s elapsed)",
self.chunk_timeout.as_secs(),
self.chunks_received,
self.stream_start.elapsed().as_secs_f64()
)));
// Save the request for replay debugging
if let Some(ref json) = self.request_json {
let log_dir = dirs::home_dir()
.unwrap_or_default()
.join(".consciousness/logs");
let ts = chrono::Local::now().format("%Y%m%dT%H%M%S");
let path = log_dir.join(format!("failed-request-{}.json", ts));
if std::fs::write(&path, json).is_ok() {
let _ = self.ui_tx.send(UiMessage::Debug(format!(
"saved failed request to {}", path.display()
)));
}
}
);
let _ = self.ui_tx.send(UiMessage::Debug(msg.clone()));
self.save_failed_request(&msg);
anyhow::bail!(
"stream timeout: no data for {}s ({} chunks received)",
self.chunk_timeout.as_secs(),