Skip to main content

rig_core/
provider_response.rs

1//! Shared logic for inspecting provider error response bodies across capability errors.
2use http::StatusCode;
3
4/// A raw error response preserved from a provider.
5///
6/// Capability errors store this in their `ProviderResponse` variants when Rig
7/// has the provider's response body in hand. Unlike `ProviderError(String)`,
8/// which may carry Rig-generated diagnostics, this type always represents the
9/// payload the provider actually returned.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub struct ProviderResponseError {
12    /// HTTP status of the provider response, when it was captured alongside the body.
13    pub status: Option<StatusCode>,
14    /// Raw response body as returned by the provider.
15    pub body: String,
16}
17
18impl ProviderResponseError {
19    pub(crate) fn without_status(body: impl Into<String>) -> Self {
20        Self {
21            status: None,
22            body: body.into(),
23        }
24    }
25}
26
27impl std::fmt::Display for ProviderResponseError {
28    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        match self.status {
30            Some(status) => write!(f, "status {status}: {}", self.body),
31            None => write!(f, "{}", self.body),
32        }
33    }
34}
35
36impl std::error::Error for ProviderResponseError {}
37
38/// Parses an optional response body as JSON.
39///
40/// Returns:
41/// - `Ok(Some(value))` when a body is present and valid JSON.
42/// - `Ok(None)` when no body is present.
43/// - `Err(error)` when a body is present but isn't valid JSON.
44pub(crate) fn json(body: Option<&str>) -> Result<Option<serde_json::Value>, serde_json::Error> {
45    body.filter(|body| !body.is_empty())
46        .map(serde_json::from_str)
47        .transpose()
48}
49
50pub(crate) fn completion_error_from_body(
51    body: impl Into<String>,
52) -> crate::completion::CompletionError {
53    crate::completion::CompletionError::ProviderResponse(ProviderResponseError::without_status(
54        body,
55    ))
56}
57
58/// Implements the `provider_response_*` inspection helpers on a capability error
59/// enum.
60///
61/// The enum must have a `ProviderResponse(`[`ProviderResponseError`]`)` variant
62/// and an `HttpError(`[`http_client::Error`](crate::http_client::Error)`)`
63/// variant; the generated helpers read from those two sources only, since they
64/// are the only ones that genuinely represent a provider's response.
65macro_rules! impl_provider_response_helpers {
66    ($error:ty) => {
67        impl $error {
68            /// Builds an error from a captured HTTP status and raw response body,
69            /// routing it so the `provider_response_*` helpers stay useful.
70            ///
71            /// This is the single funnel every HTTP-error path should use instead
72            /// of flattening a status and body into a `ProviderError(String)`:
73            /// - A **success (2xx)** status carries a provider-authored error
74            ///   envelope, so it is preserved as [`Self::ProviderResponse`]
75            ///   together with the status.
76            /// - A **non-success** status is preserved as
77            ///   [`Self::HttpError`]`(`[`http_client::Error::InvalidStatusCodeWithMessage`](crate::http_client::Error::InvalidStatusCodeWithMessage)`)`.
78            ///
79            /// Either way the raw `body` is kept verbatim and the status stays
80            /// recoverable through [`Self::provider_response_status`]. Read the
81            /// response body exactly once and hand it here for both branches.
82            pub fn from_http_response(status: http::StatusCode, body: impl Into<String>) -> Self {
83                if status.is_success() {
84                    Self::ProviderResponse($crate::provider_response::ProviderResponseError {
85                        status: Some(status),
86                        body: body.into(),
87                    })
88                } else {
89                    Self::HttpError($crate::http_client::Error::InvalidStatusCodeWithMessage(
90                        status,
91                        body.into(),
92                    ))
93                }
94            }
95
96            /// Preserves a raw provider error body that has **no HTTP status**.
97            ///
98            /// Use this for non-HTTP transports (gRPC / SDK clients such as AWS
99            /// Bedrock, Vertex AI, or the gRPC Gemini client) where the provider
100            /// returns an error payload but no [`http::StatusCode`] is available.
101            /// The body is preserved as [`Self::ProviderResponse`] with
102            /// `status == None`, so [`Self::provider_response_body`] still surfaces
103            /// it while [`Self::provider_response_status`] returns `None`.
104            pub fn from_provider_body(body: impl Into<String>) -> Self {
105                Self::ProviderResponse(
106                    $crate::provider_response::ProviderResponseError::without_status(body),
107                )
108            }
109
110            /// Returns the raw provider response body when available.
111            ///
112            /// This is available for:
113            /// - `Self::ProviderResponse` using its preserved body.
114            /// - `Self::HttpError` when it wraps an HTTP non-success response that
115            ///   carries a body.
116            ///
117            /// Returns `None` for any other variant — for example a Rig-generated
118            /// `ProviderError` diagnostic, or a failure from a transport with no
119            /// provider response body to preserve. An empty preserved body is
120            /// reported as `Some("")` (the provider returned no payload), which is
121            /// distinct from `None`; note that [`Self::provider_response_json`]
122            /// maps that same empty body to `Ok(None)`.
123            pub fn provider_response_body(&self) -> Option<&str> {
124                match self {
125                    Self::ProviderResponse(response) => Some(response.body.as_str()),
126                    Self::HttpError(error) => error.non_success_body(),
127                    _ => None,
128                }
129            }
130
131            /// Parses the provider response body as JSON.
132            ///
133            /// Returns:
134            /// - `Ok(Some(value))` when a body is present and valid JSON.
135            /// - `Ok(None)` when no provider response body is available.
136            /// - `Err(error)` when a body is present but isn't valid JSON.
137            pub fn provider_response_json(
138                &self,
139            ) -> Result<Option<serde_json::Value>, serde_json::Error> {
140                $crate::provider_response::json(self.provider_response_body())
141            }
142
143            /// Returns the HTTP status code when this error preserves one, either
144            /// from a non-success HTTP response, from a preserved provider
145            /// response, or from a 2xx error envelope.
146            ///
147            /// **Warning:** this can return a **2xx** status. Some providers send
148            /// an error envelope alongside a success status, which Rig preserves
149            /// via [`Self::ProviderResponse`]. Callers must not infer failure from
150            /// the status code alone — the existence of this error already means
151            /// the call failed. Returns `None` for non-HTTP transports (gRPC / SDK
152            /// clients) and for variants that carry no provider response.
153            pub fn provider_response_status(&self) -> Option<http::StatusCode> {
154                match self {
155                    Self::ProviderResponse(response) => response.status,
156                    Self::HttpError(error) => error.non_success_status(),
157                    _ => None,
158                }
159            }
160        }
161    };
162}
163
164pub(crate) use impl_provider_response_helpers;
165
166#[cfg(test)]
167mod tests {
168    use http::StatusCode;
169
170    /// Asserts the shared funnel preserves a provider's status + body across the
171    /// three routes every capability error exposes: a non-success HTTP response,
172    /// a 2xx provider error envelope, and a non-HTTP (gRPC/SDK) transport.
173    macro_rules! assert_funnel {
174        ($err:ty) => {{
175            let body = r#"{"error":{"message":"boom"}}"#;
176
177            // Non-success status -> HttpError, with status + body recoverable.
178            let err = <$err>::from_http_response(StatusCode::SERVICE_UNAVAILABLE, body);
179            assert_eq!(
180                err.provider_response_status(),
181                Some(StatusCode::SERVICE_UNAVAILABLE),
182                concat!(stringify!($err), ": non-success status not preserved"),
183            );
184            assert_eq!(
185                err.provider_response_body(),
186                Some(body),
187                concat!(stringify!($err), ": non-success body not preserved"),
188            );
189            assert_eq!(
190                err.provider_response_json()
191                    .expect("valid json")
192                    .expect("present json")["error"]["message"],
193                "boom",
194            );
195
196            // A provider error envelope returned with a 2xx status -> ProviderResponse,
197            // preserving the (success) status so callers can still see it.
198            let err = <$err>::from_http_response(StatusCode::OK, body);
199            assert_eq!(
200                err.provider_response_status(),
201                Some(StatusCode::OK),
202                concat!(stringify!($err), ": 2xx envelope status not preserved"),
203            );
204            assert_eq!(err.provider_response_body(), Some(body));
205
206            // No HTTP status available (gRPC/SDK) -> ProviderResponse with status None.
207            let err = <$err>::from_provider_body(body);
208            assert_eq!(
209                err.provider_response_status(),
210                None,
211                concat!(
212                    stringify!($err),
213                    ": status should be None for provider body"
214                ),
215            );
216            assert_eq!(err.provider_response_body(), Some(body));
217
218            // Empty-body asymmetry: the body is `Some("")` but JSON parses to `Ok(None)`.
219            let err = <$err>::from_provider_body("");
220            assert_eq!(err.provider_response_body(), Some(""));
221            assert!(err.provider_response_json().expect("ok").is_none());
222        }};
223    }
224
225    #[test]
226    fn funnel_preserves_status_and_body_for_every_capability_error() {
227        assert_funnel!(crate::completion::CompletionError);
228        assert_funnel!(crate::embeddings::embedding::EmbeddingError);
229        assert_funnel!(crate::transcription::TranscriptionError);
230        assert_funnel!(crate::client::verify::VerifyError);
231        assert_funnel!(crate::rerank::RerankError);
232        #[cfg(feature = "image")]
233        assert_funnel!(crate::image_generation::ImageGenerationError);
234        #[cfg(feature = "audio")]
235        assert_funnel!(crate::audio_generation::AudioGenerationError);
236    }
237
238    /// `PromptError` advertises that it forwards the `provider_response_*` helpers
239    /// to a wrapped `CompletionError`; this confirms the status, body, and JSON all
240    /// surface through the wrapper unchanged.
241    #[test]
242    fn prompt_error_forwards_provider_response_to_completion_error() {
243        let body = r#"{"error":{"message":"boom"}}"#;
244        let inner = crate::completion::CompletionError::from_http_response(
245            StatusCode::SERVICE_UNAVAILABLE,
246            body,
247        );
248        let err = crate::completion::PromptError::CompletionError(inner);
249
250        assert_eq!(
251            err.provider_response_status(),
252            Some(StatusCode::SERVICE_UNAVAILABLE),
253        );
254        assert_eq!(err.provider_response_body(), Some(body));
255        assert_eq!(
256            err.provider_response_json()
257                .expect("valid json")
258                .expect("present json")["error"]["message"],
259            "boom",
260        );
261    }
262}