From 2a642871add46d7239ba1cbaf712fa868a792939 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Tue, 7 Apr 2026 14:21:52 +0900 Subject: [PATCH] fix(api): enrich JSON parse errors with response body, provider, and model Raw 'json_error: no field X' now includes truncated response body, provider name, and model ID for debugging context. --- rust/crates/api/src/error.rs | 135 ++++++++++++++++-- rust/crates/api/src/providers/anthropic.rs | 45 +++--- .../crates/api/src/providers/openai_compat.rs | 37 +++-- rust/crates/api/src/sse.rs | 33 ++++- 4 files changed, 210 insertions(+), 40 deletions(-) diff --git a/rust/crates/api/src/error.rs b/rust/crates/api/src/error.rs index bc7dfad..e50d1ea 100644 --- a/rust/crates/api/src/error.rs +++ b/rust/crates/api/src/error.rs @@ -35,7 +35,12 @@ pub enum ApiError { InvalidApiKeyEnv(VarError), Http(reqwest::Error), Io(std::io::Error), - Json(serde_json::Error), + Json { + provider: String, + model: String, + body_snippet: String, + source: serde_json::Error, + }, Api { status: reqwest::StatusCode, error_type: Option, @@ -64,6 +69,25 @@ impl ApiError { Self::MissingCredentials { provider, env_vars } } + /// Build a `Self::Json` enriched with the provider name, the model that + /// was requested, and the first 200 characters of the raw response body so + /// that callers can diagnose deserialization failures without re-running + /// the request. + #[must_use] + pub fn json_deserialize( + provider: impl Into, + model: impl Into, + body: &str, + source: serde_json::Error, + ) -> Self { + Self::Json { + provider: provider.into(), + model: model.into(), + body_snippet: truncate_body_snippet(body, 200), + source, + } + } + #[must_use] pub fn is_retryable(&self) -> bool { match self { @@ -76,7 +100,7 @@ impl ApiError { | Self::Auth(_) | Self::InvalidApiKeyEnv(_) | Self::Io(_) - | Self::Json(_) + | Self::Json { .. } | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => false, } @@ -94,7 +118,7 @@ impl ApiError { | Self::InvalidApiKeyEnv(_) | Self::Http(_) | Self::Io(_) - | Self::Json(_) + | Self::Json { .. } | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => None, } @@ -120,7 +144,7 @@ impl ApiError { Self::Http(_) | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => { "provider_transport" } - Self::InvalidApiKeyEnv(_) | Self::Io(_) | Self::Json(_) => "runtime_io", + Self::InvalidApiKeyEnv(_) | Self::Io(_) | Self::Json { .. } => "runtime_io", } } @@ -141,7 +165,7 @@ impl ApiError { | Self::InvalidApiKeyEnv(_) | Self::Http(_) | Self::Io(_) - | Self::Json(_) + | Self::Json { .. } | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => false, } @@ -170,7 +194,7 @@ impl ApiError { | Self::InvalidApiKeyEnv(_) | Self::Http(_) | Self::Io(_) - | Self::Json(_) + | Self::Json { .. } | Self::InvalidSseFrame(_) | Self::BackoffOverflow { .. } => false, } @@ -207,7 +231,15 @@ impl Display for ApiError { } Self::Http(error) => write!(f, "http error: {error}"), Self::Io(error) => write!(f, "io error: {error}"), - Self::Json(error) => write!(f, "json error: {error}"), + Self::Json { + provider, + model, + body_snippet, + source, + } => write!( + f, + "failed to parse {provider} response for model {model}: {source}; first 200 chars of body: {body_snippet}" + ), Self::Api { status, error_type, @@ -262,7 +294,12 @@ impl From for ApiError { impl From for ApiError { fn from(value: serde_json::Error) -> Self { - Self::Json(value) + Self::Json { + provider: "unknown".to_string(), + model: "unknown".to_string(), + body_snippet: String::new(), + source: value, + } } } @@ -286,9 +323,89 @@ fn looks_like_context_window_error(text: &str) -> bool { .any(|marker| lowered.contains(marker)) } +/// Truncate `body` so the resulting snippet contains at most `max_chars` +/// characters (counted by Unicode scalar values, not bytes), preserving the +/// leading slice of the body that the caller most often needs to inspect. +fn truncate_body_snippet(body: &str, max_chars: usize) -> String { + let mut taken_chars = 0; + let mut byte_end = 0; + for (offset, character) in body.char_indices() { + if taken_chars >= max_chars { + break; + } + taken_chars += 1; + byte_end = offset + character.len_utf8(); + } + if taken_chars >= max_chars && byte_end < body.len() { + format!("{}…", &body[..byte_end]) + } else { + body[..byte_end].to_string() + } +} + #[cfg(test)] mod tests { - use super::ApiError; + use super::{truncate_body_snippet, ApiError}; + + #[test] + fn json_deserialize_error_includes_provider_model_and_truncated_body_snippet() { + let raw_body = format!("{}{}", "x".repeat(190), "_TAIL_PAST_200_CHARS_MARKER_"); + let source = serde_json::from_str::("{not json") + .expect_err("invalid json should fail to parse"); + + let error = ApiError::json_deserialize("Anthropic", "claude-opus-4-6", &raw_body, source); + let rendered = error.to_string(); + + assert!( + rendered.starts_with("failed to parse Anthropic response for model claude-opus-4-6: "), + "rendered error should lead with provider and model: {rendered}" + ); + assert!( + rendered.contains("first 200 chars of body: "), + "rendered error should label the body snippet: {rendered}" + ); + let snippet = rendered + .split("first 200 chars of body: ") + .nth(1) + .expect("snippet section should be present"); + assert!( + snippet.starts_with(&"x".repeat(190)), + "snippet should preserve the leading characters of the body: {snippet}" + ); + assert!( + snippet.ends_with('…'), + "snippet should signal truncation with an ellipsis: {snippet}" + ); + assert!( + !snippet.contains("_TAIL_PAST_200_CHARS_MARKER_"), + "snippet should drop characters past the 200-char cap: {snippet}" + ); + assert_eq!(error.safe_failure_class(), "runtime_io"); + assert_eq!(error.request_id(), None); + assert!(!error.is_retryable()); + } + + #[test] + fn truncate_body_snippet_keeps_short_bodies_intact() { + assert_eq!(truncate_body_snippet("hello", 200), "hello"); + assert_eq!(truncate_body_snippet("", 200), ""); + } + + #[test] + fn truncate_body_snippet_caps_long_bodies_at_max_chars() { + let body = "a".repeat(250); + let snippet = truncate_body_snippet(&body, 200); + assert_eq!(snippet.chars().count(), 201, "200 chars + ellipsis"); + assert!(snippet.ends_with('…')); + assert!(snippet.starts_with(&"a".repeat(200))); + } + + #[test] + fn truncate_body_snippet_does_not_split_multibyte_characters() { + let body = "한글한글한글한글한글한글"; + let snippet = truncate_body_snippet(body, 4); + assert_eq!(snippet, "한글한글…"); + } #[test] fn detects_generic_fatal_wrapper_and_classifies_it_as_provider_internal() { diff --git a/rust/crates/api/src/providers/anthropic.rs b/rust/crates/api/src/providers/anthropic.rs index f209137..8b1ccae 100644 --- a/rust/crates/api/src/providers/anthropic.rs +++ b/rust/crates/api/src/providers/anthropic.rs @@ -296,12 +296,12 @@ impl AnthropicClient { self.preflight_message_request(&request).await?; - let response = self.send_with_retry(&request).await?; - let request_id = request_id_from_headers(response.headers()); - let mut response = response - .json::() - .await - .map_err(ApiError::from)?; + let http_response = self.send_with_retry(&request).await?; + let request_id = request_id_from_headers(http_response.headers()); + let body = http_response.text().await.map_err(ApiError::from)?; + let mut response = serde_json::from_str::(&body).map_err(|error| { + ApiError::json_deserialize("Anthropic", &request.model, &body, error) + })?; if response.request_id.is_none() { response.request_id = request_id; } @@ -346,7 +346,7 @@ impl AnthropicClient { Ok(MessageStream { request_id: request_id_from_headers(response.headers()), response, - parser: SseParser::new(), + parser: SseParser::new().with_context("Anthropic", request.model.clone()), pending: VecDeque::new(), done: false, request: request.clone(), @@ -371,10 +371,10 @@ impl AnthropicClient { .await .map_err(ApiError::from)?; let response = expect_success(response).await?; - response - .json::() - .await - .map_err(ApiError::from) + let body = response.text().await.map_err(ApiError::from)?; + serde_json::from_str::(&body).map_err(|error| { + ApiError::json_deserialize("Anthropic OAuth (exchange)", "n/a", &body, error) + }) } pub async fn refresh_oauth_token( @@ -391,10 +391,10 @@ impl AnthropicClient { .await .map_err(ApiError::from)?; let response = expect_success(response).await?; - response - .json::() - .await - .map_err(ApiError::from) + let body = response.text().await.map_err(ApiError::from)?; + serde_json::from_str::(&body).map_err(|error| { + ApiError::json_deserialize("Anthropic OAuth (refresh)", "n/a", &body, error) + }) } async fn send_with_retry( @@ -523,11 +523,16 @@ impl AnthropicClient { .await .map_err(ApiError::from)?; - let parsed = expect_success(response) - .await? - .json::() - .await - .map_err(ApiError::from)?; + let response = expect_success(response).await?; + let body = response.text().await.map_err(ApiError::from)?; + let parsed = serde_json::from_str::(&body).map_err(|error| { + ApiError::json_deserialize( + "Anthropic count_tokens", + &request.model, + &body, + error, + ) + })?; Ok(parsed.input_tokens) } diff --git a/rust/crates/api/src/providers/openai_compat.rs b/rust/crates/api/src/providers/openai_compat.rs index 37e877f..6467247 100644 --- a/rust/crates/api/src/providers/openai_compat.rs +++ b/rust/crates/api/src/providers/openai_compat.rs @@ -131,7 +131,15 @@ impl OpenAiCompatClient { preflight_message_request(&request)?; let response = self.send_with_retry(&request).await?; let request_id = request_id_from_headers(response.headers()); - let payload = response.json::().await?; + let body = response.text().await.map_err(ApiError::from)?; + let payload = serde_json::from_str::(&body).map_err(|error| { + ApiError::json_deserialize( + self.config.provider_name, + &request.model, + &body, + error, + ) + })?; let mut normalized = normalize_response(&request.model, payload)?; if normalized.request_id.is_none() { normalized.request_id = request_id; @@ -150,7 +158,10 @@ impl OpenAiCompatClient { Ok(MessageStream { request_id: request_id_from_headers(response.headers()), response, - parser: OpenAiSseParser::new(), + parser: OpenAiSseParser::with_context( + self.config.provider_name, + request.model.clone(), + ), pending: VecDeque::new(), done: false, state: StreamState::new(request.model.clone()), @@ -282,11 +293,17 @@ impl MessageStream { #[derive(Debug, Default)] struct OpenAiSseParser { buffer: Vec, + provider: String, + model: String, } impl OpenAiSseParser { - fn new() -> Self { - Self::default() + fn with_context(provider: impl Into, model: impl Into) -> Self { + Self { + buffer: Vec::new(), + provider: provider.into(), + model: model.into(), + } } fn push(&mut self, chunk: &[u8]) -> Result, ApiError> { @@ -294,7 +311,7 @@ impl OpenAiSseParser { let mut events = Vec::new(); while let Some(frame) = next_sse_frame(&mut self.buffer) { - if let Some(event) = parse_sse_frame(&frame)? { + if let Some(event) = parse_sse_frame(&frame, &self.provider, &self.model)? { events.push(event); } } @@ -835,7 +852,11 @@ fn next_sse_frame(buffer: &mut Vec) -> Option { Some(String::from_utf8_lossy(&frame[..frame_len]).into_owned()) } -fn parse_sse_frame(frame: &str) -> Result, ApiError> { +fn parse_sse_frame( + frame: &str, + provider: &str, + model: &str, +) -> Result, ApiError> { let trimmed = frame.trim(); if trimmed.is_empty() { return Ok(None); @@ -857,9 +878,9 @@ fn parse_sse_frame(frame: &str) -> Result, ApiError> if payload == "[DONE]" { return Ok(None); } - serde_json::from_str(&payload) + serde_json::from_str::(&payload) .map(Some) - .map_err(ApiError::from) + .map_err(|error| ApiError::json_deserialize(provider, model, &payload, error)) } fn read_env_non_empty(key: &str) -> Result, ApiError> { diff --git a/rust/crates/api/src/sse.rs b/rust/crates/api/src/sse.rs index 0146bfa..551dfd6 100644 --- a/rust/crates/api/src/sse.rs +++ b/rust/crates/api/src/sse.rs @@ -4,6 +4,8 @@ use crate::types::StreamEvent; #[derive(Debug, Default)] pub struct SseParser { buffer: Vec, + provider: Option, + model: Option, } impl SseParser { @@ -12,12 +14,23 @@ impl SseParser { Self::default() } + /// Attach the provider name and model to this parser so that JSON + /// deserialization failures within streamed frames carry enough context + /// for callers to understand which upstream produced the unparseable + /// payload. + #[must_use] + pub fn with_context(mut self, provider: impl Into, model: impl Into) -> Self { + self.provider = Some(provider.into()); + self.model = Some(model.into()); + self + } + pub fn push(&mut self, chunk: &[u8]) -> Result, ApiError> { self.buffer.extend_from_slice(chunk); let mut events = Vec::new(); while let Some(frame) = self.next_frame() { - if let Some(event) = parse_frame(&frame)? { + if let Some(event) = self.parse_frame_with_context(&frame)? { events.push(event); } } @@ -31,12 +44,18 @@ impl SseParser { } let trailing = std::mem::take(&mut self.buffer); - match parse_frame(&String::from_utf8_lossy(&trailing))? { + match self.parse_frame_with_context(&String::from_utf8_lossy(&trailing))? { Some(event) => Ok(vec![event]), None => Ok(Vec::new()), } } + fn parse_frame_with_context(&self, frame: &str) -> Result, ApiError> { + let provider = self.provider.as_deref().unwrap_or("unknown"); + let model = self.model.as_deref().unwrap_or("unknown"); + parse_frame_with_provider(frame, provider, model) + } + fn next_frame(&mut self) -> Option { let separator = self .buffer @@ -61,6 +80,14 @@ impl SseParser { } pub fn parse_frame(frame: &str) -> Result, ApiError> { + parse_frame_with_provider(frame, "unknown", "unknown") +} + +pub(crate) fn parse_frame_with_provider( + frame: &str, + provider: &str, + model: &str, +) -> Result, ApiError> { let trimmed = frame.trim(); if trimmed.is_empty() { return Ok(None); @@ -97,7 +124,7 @@ pub fn parse_frame(frame: &str) -> Result, ApiError> { serde_json::from_str::(&payload) .map(Some) - .map_err(ApiError::from) + .map_err(|error| ApiError::json_deserialize(provider, model, &payload, error)) } #[cfg(test)]