From 741617dfefd2e3ec94d296e7a1bf422330586e52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20LIARD?= Date: Tue, 28 Jul 2026 23:07:05 +0200 Subject: [PATCH] feat(errors): expose upstream provider errors as structured JSON MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On an upstream failure grob already forwards the verbatim HTTP status and the provider's body (better than gateways that flatten a structured error into an empty 500). But the body reached the client only as a `message` string, often prefixed ("anthropic API error: {…}"), so a client could not read the provider's own `error.type`/`code` without re-parsing a blob. `into_response` now extracts the first embedded JSON object/array from the body and attaches it as `error.upstream_error`, alongside the existing string `message`, `provider` and `upstream_status`. Additive and backward compatible — clients reading `message` are unaffected; agents that branch on the provider's error type now can. Tested: `extract_leading_json` (prefix / pure / trailing-junk / none) and a 429 ProviderUpstream response asserting status 429 + parseable `upstream_error`. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/server/error.rs | 61 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/src/server/error.rs b/src/server/error.rs index 4a56c70..22b06a3 100644 --- a/src/server/error.rs +++ b/src/server/error.rs @@ -241,6 +241,19 @@ impl RequestError { } } +/// Extracts the first embedded JSON object/array from a string. +/// +/// Upstream error bodies reach us as `message` strings, sometimes with a human +/// prefix (`"anthropic API error: {…}"`). This finds the first `{` or `[` and +/// parses one JSON value from there, ignoring any trailing text, so a provider's +/// structured error can be re-exposed. Returns `None` when there is no JSON. +fn extract_leading_json(s: &str) -> Option { + let start = s.find(['{', '['])?; + let mut stream = + serde_json::Deserializer::from_str(&s[start..]).into_iter::(); + stream.next()?.ok() +} + impl IntoResponse for RequestError { fn into_response(self) -> Response { let (status, error_type, message) = self.parts(); @@ -270,7 +283,9 @@ impl IntoResponse for RequestError { } } RequestError::ProviderUpstream { - provider, status, .. + provider, + status, + body, } => { if let Some(error) = body_obj.get_mut("error").and_then(|v| v.as_object_mut()) { error.insert( @@ -281,6 +296,13 @@ impl IntoResponse for RequestError { "upstream_status".to_string(), serde_json::Value::from(*status), ); + // The upstream body is carried in `message` as a string (often + // prefixed, e.g. "anthropic API error: {…}"). Surface the + // provider's own JSON error structured as well, so a client can + // read its `type`/`code` instead of re-parsing a string blob. + if let Some(parsed) = body.as_deref().and_then(extract_leading_json) { + error.insert("upstream_error".to_string(), parsed); + } } } RequestError::ProviderProtocol { provider, .. } => { @@ -511,6 +533,43 @@ mod tests { (status, json) } + #[test] + fn extract_leading_json_handles_prefix_pure_and_none() { + // Prefixed provider body (the common case). + let v = extract_leading_json( + r#"anthropic API error: {"type":"error","error":{"type":"rate_limit_error"}}"#, + ) + .expect("must parse the embedded object"); + assert_eq!(v["error"]["type"], "rate_limit_error"); + // Pure JSON, and trailing junk after the object is ignored. + assert_eq!(extract_leading_json(r#"{"a":1} trailing"#).unwrap()["a"], 1); + // No JSON at all. + assert!(extract_leading_json("connection refused").is_none()); + } + + #[tokio::test] + async fn provider_upstream_forwards_status_and_structured_error() { + let err = RequestError::ProviderUpstream { + provider: "anthropic".to_string(), + status: 429, + body: Some( + r#"anthropic API error: {"type":"error","error":{"type":"rate_limit_error","message":"slow down"}}"# + .to_string(), + ), + }; + let (status, json) = error_response_parts(err).await; + + // Verbatim upstream status, not a flattened 500. + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!(json["error"]["upstream_status"], 429); + assert_eq!(json["error"]["provider"], "anthropic"); + // The provider's own error is now parseable, not just a string blob. + assert_eq!( + json["error"]["upstream_error"]["error"]["type"], + "rate_limit_error" + ); + } + #[tokio::test] async fn parse_error_returns_400_with_invalid_request_type() { let err = RequestError::ParseError("invalid JSON at line 1".to_string());