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}