feat(diagnostics): log upstream body preview when chat completion JSON parse fails

When an upstream chat-completion provider returns 2xx with a body the client
cannot decode as JSON (HTML challenge pages, plain-text proxy errors, empty
bodies, SSE on a non-stream endpoint, etc.), the failure used to silently bubble
up as `Failed to parse generation JSON` with no log of what was actually
received, leaving operators without any signal to diagnose CDN/proxy issues.

Introduce a shared body-preview helper in
`infrastructure/apis/http_chat_completion_repository/body_preview.rs`:

* `log_upstream_body_parse_failure(provider, operation, status, content_type,
  body, error)` emits a structured `tracing::error!` event with a UTF-8 lossy
  preview of the first 512 bytes plus the full body length, so logs stay bounded
  even when upstream pours out megabytes of HTML.
* `read_upstream_json_body(provider, operation, response)` is the async wrapper
  every HTTP provider call site uses: it reads the body once, attempts JSON
  decode, calls the logger on failure, and surfaces a more diagnostic error
  string (`Failed to parse {operation} JSON from {provider} (status {n})`).

All twelve `response.json::<Value>().await.map_err(...)` call sites across the
seven provider modules (`openai`, `claude`, `cohere`, `makersuite`,
`gemini_interactions`, `vertexai`, `openai_responses`) now route through the
helper. The OpenAI Responses WebSocket message parser
(`parse_ws_event`) also calls the logger before returning so non-JSON frames
emit the same diagnostic.

Error classification is intentionally unchanged in this commit (still
`DomainError::InternalError`); the follow-up will reclassify it as transient.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
yhny1001 2026-05-18 20:50:21 +08:00
parent 7acd1b7635
commit fdc2cea60d
9 changed files with 141 additions and 33 deletions

View file

@ -0,0 +1,114 @@
use std::fmt::Display;
use reqwest::Response;
use reqwest::StatusCode;
use reqwest::header::CONTENT_TYPE;
use serde_json::Value;
use crate::domain::errors::DomainError;
/// Maximum number of bytes from a non-JSON upstream body to capture in the
/// diagnostic log. Anything larger is truncated; the full `body_len` is still
/// emitted so log readers know how much was elided.
pub(super) const BODY_PREVIEW_BYTES: usize = 512;
/// Emit a structured `error!` event describing why a chat-completion upstream
/// body could not be parsed as JSON. Always truncates the body to
/// [`BODY_PREVIEW_BYTES`] so we don't blow up the log pipeline on huge HTML
/// challenges, plain-text errors, or runaway bodies.
pub(super) fn log_upstream_body_parse_failure(
provider_name: &str,
operation: &str,
status: StatusCode,
content_type: &str,
body: &[u8],
error: &impl Display,
) {
let body_len = body.len();
let body_preview = body_preview_string(body);
tracing::error!(
provider = provider_name,
operation = operation,
status = status.as_u16(),
content_type = content_type,
body_len,
body_preview = %body_preview,
error = %error,
"upstream returned non-JSON body for chat completion",
);
}
/// Lossy UTF-8 preview of the first [`BODY_PREVIEW_BYTES`] of a body.
pub(super) fn body_preview_string(body: &[u8]) -> String {
let preview_bytes = &body[..body.len().min(BODY_PREVIEW_BYTES)];
String::from_utf8_lossy(preview_bytes).into_owned()
}
/// Read an upstream HTTP response body and parse it as JSON, logging a
/// detailed diagnostic event on failure. Caller is responsible for ensuring
/// the response status was 2xx before invoking this helper.
pub(super) async fn read_upstream_json_body(
provider_name: &str,
operation: &str,
response: Response,
) -> Result<Value, DomainError> {
let status = response.status();
let content_type = response
.headers()
.get(CONTENT_TYPE)
.and_then(|value| value.to_str().ok())
.unwrap_or("")
.to_string();
let body = response.bytes().await.map_err(|error| {
DomainError::InternalError(format!(
"Failed to read {operation} body from {provider_name} (status {}): {error}",
status.as_u16()
))
})?;
match serde_json::from_slice::<Value>(&body) {
Ok(value) => Ok(value),
Err(error) => {
log_upstream_body_parse_failure(
provider_name,
operation,
status,
&content_type,
&body,
&error,
);
Err(DomainError::InternalError(format!(
"Failed to parse {operation} JSON from {provider_name} (status {}): {error}",
status.as_u16()
)))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn body_preview_string_truncates_to_first_512_bytes() {
let body = vec![b'a'; BODY_PREVIEW_BYTES + 64];
let preview = body_preview_string(&body);
assert_eq!(preview.len(), BODY_PREVIEW_BYTES);
assert!(preview.chars().all(|character| character == 'a'));
}
#[test]
fn body_preview_string_returns_short_body_verbatim() {
let body = b"<html>error</html>";
assert_eq!(body_preview_string(body), "<html>error</html>");
}
#[test]
fn body_preview_string_replaces_invalid_utf8_lossily() {
let body = vec![0xff, 0xfe, b'O', b'K'];
let preview = body_preview_string(&body);
assert!(preview.contains("OK"));
}
}

View file

@ -11,6 +11,7 @@ use crate::domain::repositories::chat_completion_repository::{
};
use super::HttpChatCompletionRepository;
use super::body_preview::read_upstream_json_body;
use super::normalizers;
const ANTHROPIC_VERSION: &str = "2023-06-01";
@ -47,9 +48,7 @@ pub(super) async fn list_models(
.await);
}
response.json::<Value>().await.map_err(|error| {
DomainError::InternalError(format!("Failed to parse models JSON: {error}"))
})
read_upstream_json_body("Claude", "list_models", response).await
}
pub(super) async fn generate(
@ -91,9 +90,7 @@ pub(super) async fn generate(
.await);
}
let body = response.json::<Value>().await.map_err(|error| {
DomainError::InternalError(format!("Failed to parse generation JSON: {error}"))
})?;
let body = read_upstream_json_body(provider_name, "generate", response).await?;
if super::payload_contains_cache_control(payload) {
let model = payload.get("model").and_then(Value::as_str);

View file

@ -7,6 +7,7 @@ use crate::domain::repositories::chat_completion_repository::{
};
use super::HttpChatCompletionRepository;
use super::body_preview::read_upstream_json_body;
pub(super) async fn list_models(
repository: &HttpChatCompletionRepository,
@ -32,9 +33,7 @@ pub(super) async fn list_models(
.await);
}
let body = response.json::<Value>().await.map_err(|error| {
DomainError::InternalError(format!("Failed to parse models JSON: {error}"))
})?;
let body = read_upstream_json_body("Cohere", "list_models", response).await?;
Ok(json!({ "data": normalize_models(&body) }))
}
@ -71,9 +70,7 @@ pub(super) async fn generate(
.await);
}
response.json::<Value>().await.map_err(|error| {
DomainError::InternalError(format!("Failed to parse generation JSON: {error}"))
})
read_upstream_json_body("Cohere", "generate", response).await
}
pub(super) async fn generate_stream(

View file

@ -12,6 +12,7 @@ use crate::domain::repositories::chat_completion_repository::{
};
use super::HttpChatCompletionRepository;
use super::body_preview::read_upstream_json_body;
use super::normalizers;
const GEMINI_API_VERSION: &str = "v1beta";
@ -387,9 +388,7 @@ pub(super) async fn generate(
.await);
}
let body = response.json::<Value>().await.map_err(|error| {
DomainError::InternalError(format!("Failed to parse generation JSON: {error}"))
})?;
let body = read_upstream_json_body(provider_name, "generate", response).await?;
Ok(normalizers::normalize_gemini_interactions_response(body))
}

View file

@ -8,6 +8,7 @@ use crate::domain::repositories::chat_completion_repository::{
};
use super::HttpChatCompletionRepository;
use super::body_preview::read_upstream_json_body;
use super::normalizers;
const GEMINI_API_VERSION: &str = "v1beta";
@ -36,9 +37,7 @@ pub(super) async fn list_models(
.await);
}
let body = response.json::<Value>().await.map_err(|error| {
DomainError::InternalError(format!("Failed to parse models JSON: {error}"))
})?;
let body = read_upstream_json_body("Google Gemini", "list_models", response).await?;
let models = body
.get("models")
@ -120,9 +119,7 @@ pub(super) async fn generate(
.await);
}
let body = response.json::<Value>().await.map_err(|error| {
DomainError::InternalError(format!("Failed to parse generation JSON: {error}"))
})?;
let body = read_upstream_json_body("Google Gemini", "generate", response).await?;
Ok(normalizers::normalize_gemini_response(body))
}

View file

@ -13,6 +13,7 @@ use crate::domain::repositories::chat_completion_repository::{
};
use crate::infrastructure::http_client_pool::{HttpClientPool, HttpClientProfile};
mod body_preview;
mod claude;
mod cohere;
mod gemini_interactions;

View file

@ -7,6 +7,7 @@ use crate::domain::repositories::chat_completion_repository::{
};
use super::HttpChatCompletionRepository;
use super::body_preview::read_upstream_json_body;
pub(super) async fn list_models(
repository: &HttpChatCompletionRepository,
@ -42,9 +43,7 @@ pub(super) async fn list_models_with_path(
.await);
}
response.json::<Value>().await.map_err(|error| {
DomainError::InternalError(format!("Failed to parse models JSON: {error}"))
})
read_upstream_json_body(provider_name, "list_models", response).await
}
pub(super) async fn generate(
@ -79,9 +78,7 @@ pub(super) async fn generate(
.await);
}
let body = response.json::<Value>().await.map_err(|error| {
DomainError::InternalError(format!("Failed to parse generation JSON: {error}"))
})?;
let body = read_upstream_json_body(provider_name, "generate", response).await?;
if super::payload_contains_cache_control(payload) {
let model = payload.get("model").and_then(Value::as_str);

View file

@ -19,6 +19,7 @@ use crate::domain::repositories::chat_completion_repository::{
};
use super::HttpChatCompletionRepository;
use super::body_preview::{log_upstream_body_parse_failure, read_upstream_json_body};
use super::normalizers;
type ResponsesWsStream = tokio_tungstenite::WebSocketStream<reqwest::Upgraded>;
@ -463,9 +464,7 @@ async fn generate_http(
.await);
}
let body = response.json::<Value>().await.map_err(|error| {
DomainError::InternalError(format!("Failed to parse generation JSON: {error}"))
})?;
let body = read_upstream_json_body(provider_name, "generate", response).await?;
Ok(normalizers::normalize_openai_responses_response(body))
}
@ -1090,6 +1089,14 @@ fn response_from_ws_payload(payload: &[u8]) -> Result<Option<Value>, DomainError
fn parse_ws_event(payload: &[u8]) -> Result<Value, DomainError> {
serde_json::from_slice(payload).map_err(|error| {
log_upstream_body_parse_failure(
"OpenAI Responses",
"generate_stream_ws",
StatusCode::SWITCHING_PROTOCOLS,
"application/json",
payload,
&error,
);
DomainError::InternalError(format!(
"OpenAI Responses WebSocket event is not valid JSON: {error}"
))

View file

@ -8,6 +8,7 @@ use crate::domain::repositories::chat_completion_repository::{
};
use super::HttpChatCompletionRepository;
use super::body_preview::read_upstream_json_body;
use super::normalizers;
const PROVIDER_NAME: &str = "Google Vertex AI";
@ -73,9 +74,7 @@ pub(super) async fn generate(
.await);
}
let body = response.json::<Value>().await.map_err(|error| {
DomainError::InternalError(format!("Failed to parse generation JSON: {error}"))
})?;
let body = read_upstream_json_body(PROVIDER_NAME, "generate", response).await?;
Ok(normalizers::normalize_gemini_response(body))
}