fix: forward Google's exact error messages to client

Root cause: errors from Google were being swallowed, replaced with
placeholders like 'Google API returned HTTP 400' or '[Timeout waiting
for response]', or silently converted to fake 'incomplete' responses.

Changes across all endpoints (/v1/chat/completions, /v1/responses,
/v1/gemini, /v1/search):

Error message fidelity:
- UpstreamError message now includes Google's status prefix: [STATUS] msg
- Falls back to raw body if JSON parsing fails (protobuf, HTML, etc.)
- ErrorDetail gains optional code and param fields

Timeout handling:
- poll_for_response returns UpstreamError(504, DEADLINE_EXCEEDED) on timeout
  instead of '[Timeout waiting for AI response]' placeholder text
- Streaming timeouts emit proper error events, not fake content
- Sync bypass timeouts return 504 Gateway Timeout, not 200 incomplete

Missing error checks added:
- responses.rs sync bypass: added upstream_error check in polling loop
- gemini.rs sync bypass: added upstream_error check in polling loop
- gemini.rs streaming: added upstream_error check in polling loop
  (was completely missing — errors only handled in sync path)

DRY helpers:
- upstream_error_message(): shared exact message extraction
- upstream_error_type(): shared Google→OpenAI error type mapping
- All streaming handlers use these instead of inline formatting
This commit is contained in:
Nikketryhard
2026-02-16 19:30:32 -06:00
parent 931e1cc5a1
commit a47c572e48
6 changed files with 171 additions and 106 deletions

View File

@@ -675,14 +675,8 @@ async fn chat_completions_stream(
while start.elapsed().as_secs() < timeout { while start.elapsed().as_secs() < timeout {
// Check for upstream errors from MITM (Google API errors) // Check for upstream errors from MITM (Google API errors)
if let Some(err) = state.mitm_store.take_upstream_error().await { if let Some(err) = state.mitm_store.take_upstream_error().await {
let error_msg = err.message.clone() let error_msg = super::util::upstream_error_message(&err);
.unwrap_or_else(|| format!("Google API returned HTTP {}", err.status)); let error_type = super::util::upstream_error_type(&err);
let error_type = match err.error_status.as_deref() {
Some("INVALID_ARGUMENT") => "invalid_request_error",
Some("RESOURCE_EXHAUSTED") => "rate_limit_error",
Some("PERMISSION_DENIED") | Some("UNAUTHENTICATED") => "authentication_error",
_ => "upstream_error",
};
yield Ok(Event::default().data(serde_json::to_string(&serde_json::json!({ yield Ok(Event::default().data(serde_json::to_string(&serde_json::json!({
"error": { "error": {
"message": error_msg, "message": error_msg,
@@ -997,26 +991,15 @@ async fn chat_completions_stream(
tokio::time::sleep(tokio::time::Duration::from_millis(poll_ms)).await; tokio::time::sleep(tokio::time::Duration::from_millis(poll_ms)).await;
} }
// Timeout // Timeout — emit error, not placeholder content
warn!("Completions stream timeout after {}s", timeout); warn!("Completions stream timeout after {}s", timeout);
let mitm = state.mitm_store.take_usage(&cascade_id).await yield Ok(Event::default().data(serde_json::to_string(&serde_json::json!({
.or(state.mitm_store.take_usage("_latest").await); "error": {
let fr = google_to_openai_finish_reason(mitm.as_ref().and_then(|u| u.stop_reason.as_deref())); "message": format!("Timeout: no response from Google API after {timeout}s"),
yield Ok(Event::default().data(chunk_json( "type": "upstream_error",
&completion_id, &model_name, "code": 504,
serde_json::json!([chunk_choice(0, serde_json::json!({"content": if last_text.is_empty() { "[Timeout waiting for response]" } else { "" }}), Some(fr))]), }
None, })).unwrap()));
)));
if include_usage {
let (pt, ct, crt, tt) = if let Some(ref u) = mitm {
(u.input_tokens, u.output_tokens, u.cache_read_input_tokens, u.thinking_output_tokens)
} else { (0, 0, 0, 0) };
yield Ok(Event::default().data(chunk_json(
&completion_id, &model_name,
serde_json::json!([]),
Some(build_usage(pt, ct, crt, tt)),
)));
}
// Always clear in-flight flag when stream ends // Always clear in-flight flag when stream ends
state.mitm_store.clear_response_async().await; state.mitm_store.clear_response_async().await;
yield Ok(Event::default().data("[DONE]")); yield Ok(Event::default().data("[DONE]"));

View File

@@ -374,6 +374,11 @@ async fn gemini_sync(
if has_custom_tools { if has_custom_tools {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
while start.elapsed().as_secs() < timeout { while start.elapsed().as_secs() < timeout {
// Check for upstream errors from MITM (Google API errors)
if let Some(err) = state.mitm_store.take_upstream_error().await {
return upstream_err_response(&err);
}
// Check for function calls // Check for function calls
let captured = state.mitm_store.take_any_function_calls().await; let captured = state.mitm_store.take_any_function_calls().await;
if let Some(ref calls) = captured { if let Some(ref calls) = captured {
@@ -444,13 +449,17 @@ async fn gemini_sync(
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
} }
// Timeout // Timeout — return proper error with status code
return Json(serde_json::json!({ return (
"error": { axum::http::StatusCode::GATEWAY_TIMEOUT,
"message": "Request timed out", Json(serde_json::json!({
"type": "timeout_error", "error": {
} "message": format!("Timeout: no response from Google API after {timeout}s"),
})) "type": "upstream_error",
"code": 504,
}
})),
)
.into_response(); .into_response();
} }
@@ -535,6 +544,21 @@ async fn gemini_stream(
state.mitm_store.clear_response_async().await; state.mitm_store.clear_response_async().await;
while start.elapsed().as_secs() < timeout { while start.elapsed().as_secs() < timeout {
// Check for upstream errors from MITM (Google API errors)
if let Some(err) = state.mitm_store.take_upstream_error().await {
let error_msg = super::util::upstream_error_message(&err);
let error_type = super::util::upstream_error_type(&err);
yield Ok::<_, std::convert::Infallible>(Event::default().data(serde_json::to_string(&serde_json::json!({
"error": {
"message": error_msg,
"type": error_type,
"code": err.status,
}
})).unwrap()));
yield Ok(Event::default().data("[DONE]".to_string()));
break;
}
// ── Check for MITM-captured function calls FIRST ── // ── Check for MITM-captured function calls FIRST ──
let captured = state.mitm_store.take_any_function_calls().await; let captured = state.mitm_store.take_any_function_calls().await;
if let Some(ref calls) = captured { if let Some(ref calls) = captured {
@@ -705,16 +729,13 @@ async fn gemini_stream(
tokio::time::sleep(tokio::time::Duration::from_millis(poll_ms)).await; tokio::time::sleep(tokio::time::Duration::from_millis(poll_ms)).await;
} }
// Timeout // Timeout — emit proper error
yield Ok(Event::default().data(serde_json::to_string(&serde_json::json!({ yield Ok(Event::default().data(serde_json::to_string(&serde_json::json!({
"candidates": [{ "error": {
"content": { "message": format!("Timeout: no response from Google API after {timeout}s"),
"parts": [{"text": if last_text.is_empty() { "[Timeout]" } else { "" }}], "type": "upstream_error",
"role": "model", "code": 504,
}, }
"finishReason": "STOP",
}],
"modelVersion": model_name,
})).unwrap_or_default())); })).unwrap_or_default()));
yield Ok(Event::default().data("[DONE]")); yield Ok(Event::default().data("[DONE]"));
}; };

View File

@@ -323,11 +323,18 @@ pub(crate) async fn poll_for_response(
warn!("Timeout after {timeout}s on cascade {short_id}"); warn!("Timeout after {timeout}s on cascade {short_id}");
PollResult { PollResult {
text: "[Timeout waiting for AI response]".to_string(), text: String::new(),
usage: None, usage: None,
thinking_signature: None, thinking_signature: None,
thinking: None, thinking: None,
thinking_duration: None, thinking_duration: None,
upstream_error: None, upstream_error: Some(crate::mitm::store::UpstreamError {
status: 504,
body: String::new(),
message: Some(format!(
"Timeout: no response from Google API after {timeout}s"
)),
error_status: Some("DEADLINE_EXCEEDED".to_string()),
}),
} }
} }

View File

@@ -615,6 +615,11 @@ async fn handle_responses_sync(
if has_custom_tools { if has_custom_tools {
let start = std::time::Instant::now(); let start = std::time::Instant::now();
while start.elapsed().as_secs() < timeout { while start.elapsed().as_secs() < timeout {
// Check for upstream errors from MITM (Google API errors)
if let Some(err) = state.mitm_store.take_upstream_error().await {
return upstream_err_response(&err);
}
// Check for function calls // Check for function calls
let captured = state.mitm_store.take_function_calls(&cascade_id).await; let captured = state.mitm_store.take_function_calls(&cascade_id).await;
if let Some(ref raw_calls) = captured { if let Some(ref raw_calls) = captured {
@@ -706,21 +711,12 @@ async fn handle_responses_sync(
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await; tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
} }
// Timeout // Timeout — return proper error, not fake incomplete response
let resp = build_response_object( return err_response(
ResponseData { StatusCode::GATEWAY_TIMEOUT,
id: response_id, format!("Timeout: no response from Google API after {timeout}s"),
model: model_name, "upstream_error",
status: "incomplete",
created_at,
completed_at: None,
output: vec![],
usage: Some(Usage::estimate(&params.user_text, "")),
thinking_signature: None,
},
&params,
); );
return Json(resp).into_response();
} }
// ── Normal LS path (no custom tools) ── // ── Normal LS path (no custom tools) ──
@@ -904,8 +900,8 @@ async fn handle_responses_stream(
while start.elapsed().as_secs() < timeout { while start.elapsed().as_secs() < timeout {
// Check for upstream errors from MITM (Google API errors) // Check for upstream errors from MITM (Google API errors)
if let Some(err) = state.mitm_store.take_upstream_error().await { if let Some(err) = state.mitm_store.take_upstream_error().await {
let error_msg = err.message.clone() let error_msg = super::util::upstream_error_message(&err);
.unwrap_or_else(|| format!("Google API returned HTTP {}", err.status)); let error_type = super::util::upstream_error_type(&err);
yield Ok(responses_sse_event( yield Ok(responses_sse_event(
"response.failed", "response.failed",
serde_json::json!({ serde_json::json!({
@@ -915,7 +911,7 @@ async fn handle_responses_stream(
"id": &response_id, "id": &response_id,
"status": "failed", "status": "failed",
"error": { "error": {
"type": err.error_status.as_deref().unwrap_or("upstream_error"), "type": error_type,
"message": error_msg, "message": error_msg,
"code": err.status, "code": err.status,
}, },
@@ -1202,26 +1198,21 @@ async fn handle_responses_stream(
tokio::time::sleep(tokio::time::Duration::from_millis(poll_ms)).await; tokio::time::sleep(tokio::time::Duration::from_millis(poll_ms)).await;
} }
// Timeout in bypass mode // Timeout in bypass mode — emit error, not fake incomplete
let timeout_resp = build_response_object(
ResponseData {
id: response_id.clone(),
model: model_name.clone(),
status: "incomplete",
created_at,
completed_at: None,
output: vec![],
usage: Some(Usage::estimate(&params.user_text, "")),
thinking_signature: None,
},
&params,
);
yield Ok(responses_sse_event( yield Ok(responses_sse_event(
"response.completed", "response.failed",
serde_json::json!({ serde_json::json!({
"type": "response.completed", "type": "response.failed",
"sequence_number": next_seq(), "sequence_number": next_seq(),
"response": response_to_json(&timeout_resp), "response": {
"id": &response_id,
"status": "failed",
"error": {
"type": "upstream_error",
"message": format!("Timeout: no response from Google API after {timeout}s"),
"code": 504,
},
},
}), }),
)); ));
return; return;
@@ -1247,8 +1238,8 @@ async fn handle_responses_stream(
while start.elapsed().as_secs() < timeout { while start.elapsed().as_secs() < timeout {
// Check for upstream errors from MITM (Google API errors) // Check for upstream errors from MITM (Google API errors)
if let Some(err) = state.mitm_store.take_upstream_error().await { if let Some(err) = state.mitm_store.take_upstream_error().await {
let error_msg = err.message.clone() let error_msg = super::util::upstream_error_message(&err);
.unwrap_or_else(|| format!("Google API returned HTTP {}", err.status)); let error_type = super::util::upstream_error_type(&err);
yield Ok(responses_sse_event( yield Ok(responses_sse_event(
"response.failed", "response.failed",
serde_json::json!({ serde_json::json!({
@@ -1258,7 +1249,7 @@ async fn handle_responses_stream(
"id": &response_id, "id": &response_id,
"status": "failed", "status": "failed",
"error": { "error": {
"type": err.error_status.as_deref().unwrap_or("upstream_error"), "type": error_type,
"message": error_msg, "message": error_msg,
"code": err.status, "code": err.status,
}, },
@@ -1507,26 +1498,21 @@ async fn handle_responses_stream(
} }
} }
// Timeout — emit incomplete response // Timeout — emit error, not fake incomplete response
let timeout_resp = build_response_object(
ResponseData {
id: response_id.clone(),
model: model_name.clone(),
status: "incomplete",
created_at,
completed_at: None,
output: vec![],
usage: Some(Usage::estimate(&params.user_text, "")),
thinking_signature: None,
},
&params,
);
yield Ok(responses_sse_event( yield Ok(responses_sse_event(
"response.completed", "response.failed",
serde_json::json!({ serde_json::json!({
"type": "response.completed", "type": "response.failed",
"sequence_number": next_seq(), "sequence_number": next_seq(),
"response": response_to_json(&timeout_resp), "response": {
"id": &response_id,
"status": "failed",
"error": {
"type": "upstream_error",
"message": format!("Timeout: no response from Google API after {timeout}s"),
"code": 504,
},
},
}), }),
)); ));
}; };

View File

@@ -410,4 +410,8 @@ pub(crate) struct ErrorDetail {
pub message: String, pub message: String,
#[serde(rename = "type")] #[serde(rename = "type")]
pub error_type: String, pub error_type: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub code: Option<u16>,
#[serde(skip_serializing_if = "Option::is_none")]
pub param: Option<String>,
} }

View File

@@ -20,17 +20,18 @@ pub(crate) fn err_response(
error: ErrorDetail { error: ErrorDetail {
message, message,
error_type: error_type.to_string(), error_type: error_type.to_string(),
code: Some(status.as_u16()),
param: None,
}, },
}; };
(status, Json(body)).into_response() (status, Json(body)).into_response()
} }
/// Convert a MITM-captured upstream error from Google into an HTTP response. /// Convert a MITM-captured upstream error from Google into an HTTP response.
/// Maps Google's HTTP status codes and preserves the error message. /// Forwards Google's exact error message and HTTP status code to the client.
pub(crate) fn upstream_err_response( pub(crate) fn upstream_err_response(
err: &crate::mitm::store::UpstreamError, err: &crate::mitm::store::UpstreamError,
) -> axum::response::Response { ) -> axum::response::Response {
// Map Google's status code to HTTP status
let status = StatusCode::from_u16(err.status).unwrap_or(StatusCode::BAD_GATEWAY); let status = StatusCode::from_u16(err.status).unwrap_or(StatusCode::BAD_GATEWAY);
// Map Google error status to OpenAI-style error type // Map Google error status to OpenAI-style error type
@@ -43,12 +44,75 @@ pub(crate) fn upstream_err_response(
_ => "upstream_error", _ => "upstream_error",
}; };
let message = err // Use Google's exact error message. Try parsed message first, then raw body.
.message let message = if let Some(ref msg) = err.message {
.clone() // Include Google's error status for context if available
.unwrap_or_else(|| format!("Google API returned HTTP {}", err.status)); if let Some(ref gstatus) = err.error_status {
format!("[{gstatus}] {msg}")
} else {
msg.clone()
}
} else if !err.body.is_empty() {
// No parsed message — forward the raw body as-is so the client
// sees exactly what Google returned (protobuf, HTML, etc.)
err.body.clone()
} else {
format!("Google API error: HTTP {}", err.status)
};
err_response(status, message, error_type) // Extract param hint from Google's error details if available
let param = serde_json::from_str::<serde_json::Value>(&err.body)
.ok()
.and_then(|v| {
v["error"]["details"]
.as_array()
.and_then(|details| {
details.iter().find_map(|d| {
d["fieldViolations"]
.as_array()
.and_then(|fv| fv.first())
.and_then(|v| v["field"].as_str().map(|s| s.to_string()))
})
})
});
let body = ErrorResponse {
error: ErrorDetail {
message,
error_type: error_type.to_string(),
code: Some(err.status),
param,
},
};
(status, Json(body)).into_response()
}
/// Extract the exact error message from a MITM-captured upstream error.
/// Preserves Google's original message verbatim. Used by streaming handlers.
pub(crate) fn upstream_error_message(err: &crate::mitm::store::UpstreamError) -> String {
if let Some(ref msg) = err.message {
if let Some(ref gstatus) = err.error_status {
format!("[{gstatus}] {msg}")
} else {
msg.clone()
}
} else if !err.body.is_empty() {
err.body.clone()
} else {
format!("Google API error: HTTP {}", err.status)
}
}
/// Map Google's error status to OpenAI-compatible error type string.
pub(crate) fn upstream_error_type(err: &crate::mitm::store::UpstreamError) -> &'static str {
match err.error_status.as_deref() {
Some("INVALID_ARGUMENT") => "invalid_request_error",
Some("RESOURCE_EXHAUSTED") => "rate_limit_error",
Some("PERMISSION_DENIED") | Some("UNAUTHENTICATED") => "authentication_error",
Some("NOT_FOUND") => "not_found_error",
Some("INTERNAL") | Some("UNAVAILABLE") => "server_error",
_ => "upstream_error",
}
} }
pub(crate) fn now_unix() -> u64 { pub(crate) fn now_unix() -> u64 {