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

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