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///
11/// Prefer [`Self::new`] / [`Self::without_status`] and the `with_*` setters
12/// over a struct literal: a literal has to be revisited every time transport
13/// metadata grows, and the constructors do not.
14#[derive(Debug, Clone, PartialEq, Eq)]
15pub struct ProviderResponseError {
16    /// HTTP status of the provider response, when it was captured alongside the body.
17    pub status: Option<StatusCode>,
18    /// Raw response body as returned by the provider.
19    pub body: String,
20    /// The provider's transport request id for the failed call (HTTP response
21    /// header such as Anthropic `request-id` / OpenAI `x-request-id`, or SDK
22    /// response metadata) — the id provider support asks for when
23    /// investigating a request, which matters most on exactly these failed
24    /// calls. `None` means the provider did not report one — a documented
25    /// outcome, never a secondary error (rig#2314).
26    pub provider_request_id: Option<String>,
27    /// The response's headers, verbatim, when the capture path had them in
28    /// hand — the rate-limit metadata (`Retry-After`, `x-ratelimit-*`) a
29    /// caller needs to back off correctly after a 429 (rig#2210). Boxed to
30    /// keep this error small enough for `clippy::result_large_err`. `None`
31    /// means "not captured", never "the response had no headers".
32    pub headers: Option<Box<http::HeaderMap>>,
33}
34
35impl ProviderResponseError {
36    /// Preserve a provider error response captured with its HTTP status.
37    pub fn new(status: StatusCode, body: impl Into<String>) -> Self {
38        Self {
39            status: Some(status),
40            body: body.into(),
41            provider_request_id: None,
42            headers: None,
43        }
44    }
45
46    /// Preserve a provider error body that has no HTTP status (gRPC / SDK
47    /// transports).
48    pub fn without_status(body: impl Into<String>) -> Self {
49        Self {
50            status: None,
51            body: body.into(),
52            provider_request_id: None,
53            headers: None,
54        }
55    }
56
57    /// Attach the transport request id the failed response reported.
58    pub fn with_provider_request_id(mut self, request_id: Option<String>) -> Self {
59        self.provider_request_id = request_id.filter(|id| !id.is_empty());
60        self
61    }
62
63    /// Attach the response's headers, so rate-limit metadata survives onto the
64    /// error (rig#2210).
65    pub fn with_headers(mut self, headers: Option<Box<http::HeaderMap>>) -> Self {
66        self.headers = headers;
67        self
68    }
69}
70
71impl std::fmt::Display for ProviderResponseError {
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        match self.status {
74            Some(status) => write!(f, "status {status}: {}", self.body)?,
75            None => write!(f, "{}", self.body)?,
76        }
77        // The id support asks for belongs in the message a caller logs.
78        if let Some(request_id) = &self.provider_request_id {
79            write!(f, " (request id: {request_id})")?;
80        }
81        Ok(())
82    }
83}
84
85impl std::error::Error for ProviderResponseError {}
86
87/// Parses an optional response body as JSON.
88///
89/// Returns:
90/// - `Ok(Some(value))` when a body is present and valid JSON.
91/// - `Ok(None)` when no body is present.
92/// - `Err(error)` when a body is present but isn't valid JSON.
93pub(crate) fn json(body: Option<&str>) -> Result<Option<serde_json::Value>, serde_json::Error> {
94    body.filter(|body| !body.is_empty())
95        .map(serde_json::from_str)
96        .transpose()
97}
98
99pub(crate) fn completion_error_from_body(
100    body: impl Into<String>,
101) -> crate::completion::CompletionError {
102    crate::completion::CompletionError::ProviderResponse(ProviderResponseError::without_status(
103        body,
104    ))
105}
106
107/// Implements the `provider_response_*` inspection helpers on a capability error
108/// enum.
109///
110/// The enum must have a `ProviderResponse(`[`ProviderResponseError`]`)` variant
111/// and an `HttpError(`[`http_client::Error`](crate::http_client::Error)`)`
112/// variant; the generated helpers read from those two sources only, since they
113/// are the only ones that genuinely represent a provider's response.
114macro_rules! impl_provider_response_helpers {
115    ($error:ty) => {
116        impl $error {
117            /// Builds an error from a captured HTTP status and raw response body,
118            /// routing it so the `provider_response_*` helpers stay useful.
119            ///
120            /// This is the single funnel every HTTP-error path should use instead
121            /// of flattening a status and body into a `ProviderError(String)`:
122            /// - A **success (2xx)** status carries a provider-authored error
123            ///   envelope, so it is preserved as [`Self::ProviderResponse`]
124            ///   together with the status.
125            /// - A **non-success** status is preserved as
126            ///   [`Self::HttpError`]`(`[`http_client::Error::InvalidStatusCodeWithMessage`](crate::http_client::Error::InvalidStatusCodeWithMessage)`)`.
127            ///
128            /// Either way the raw `body` is kept verbatim and the status stays
129            /// recoverable through [`Self::provider_response_status`]. Read the
130            /// response body exactly once and hand it here for both branches.
131            pub fn from_http_response(status: http::StatusCode, body: impl Into<String>) -> Self {
132                if status.is_success() {
133                    Self::ProviderResponse($crate::provider_response::ProviderResponseError::new(
134                        status, body,
135                    ))
136                } else {
137                    Self::HttpError($crate::http_client::Error::InvalidStatusCodeWithMessage(
138                        status,
139                        body.into(),
140                    ))
141                }
142            }
143
144            /// [`Self::from_http_response`] for paths that captured the
145            /// provider's transport request id alongside the response
146            /// (rig#2314).
147            ///
148            /// Unlike the metadata-less funnel, a **non-success** status is
149            /// preserved as [`Self::ProviderResponse`] too — `http_client`'s
150            /// error type has no slot for provider metadata, and the id the
151            /// provider reported on a failed call is exactly what support
152            /// asks for. Classification therefore follows the *code path*
153            /// (did this call site capture transport metadata?), never the
154            /// presence of the header on a particular response, so a given
155            /// provider's errors classify consistently. The status stays
156            /// recoverable through [`Self::provider_response_status`] and the
157            /// id through [`Self::provider_request_id`].
158            pub fn from_http_response_with_request_id(
159                status: http::StatusCode,
160                body: impl Into<String>,
161                provider_request_id: Option<String>,
162            ) -> Self {
163                Self::ProviderResponse(
164                    $crate::provider_response::ProviderResponseError::new(status, body)
165                        .with_provider_request_id(provider_request_id),
166                )
167            }
168
169            /// Attaches the response's headers to an error just built by one of
170            /// the `from_http_response*` funnels, so rate-limit metadata
171            /// (`Retry-After`, `x-ratelimit-*`) survives onto it (rig#2210).
172            ///
173            /// This is a separate step rather than a funnel parameter because
174            /// the funnels' classification is fixed by the *call path* (does
175            /// this provider have a request-id contract?), while header capture
176            /// depends only on whether the transport handed the response back.
177            /// Both routes can therefore carry headers:
178            /// [`Self::ProviderResponse`] stores them alongside the request id,
179            /// and a non-success [`Self::HttpError`] is upgraded in place to
180            /// [`http_client::Error::InvalidStatusCodeWithDetails`](crate::http_client::Error::InvalidStatusCodeWithDetails),
181            /// which displays identically to the header-less variant.
182            ///
183            /// Passing `None` leaves the error untouched, as does calling this
184            /// on a variant with no response to annotate. An error that already
185            /// captured headers keeps the ones it has: the first capture is the
186            /// one that saw the response, so this never overwrites.
187            pub fn with_response_headers(self, headers: Option<Box<http::HeaderMap>>) -> Self {
188                let Some(headers) = headers else {
189                    return self;
190                };
191                match self {
192                    // The first capture is the one that saw the response; a
193                    // later caller only fills the gap, mirroring how the
194                    // request-id slot is stamped (rig#2314).
195                    Self::ProviderResponse(response) if response.headers.is_none() => {
196                        Self::ProviderResponse(response.with_headers(Some(headers)))
197                    }
198                    Self::HttpError($crate::http_client::Error::InvalidStatusCodeWithMessage(
199                        status,
200                        body,
201                    )) => {
202                        Self::HttpError($crate::http_client::Error::InvalidStatusCodeWithDetails {
203                            status,
204                            body,
205                            headers,
206                        })
207                    }
208                    other => other,
209                }
210            }
211
212            /// Preserves a raw provider error body that has **no HTTP status**.
213            ///
214            /// Use this for non-HTTP transports (gRPC / SDK clients such as AWS
215            /// Bedrock, Vertex AI, or the gRPC Gemini client) where the provider
216            /// returns an error payload but no [`http::StatusCode`] is available.
217            /// The body is preserved as [`Self::ProviderResponse`] with
218            /// `status == None`, so [`Self::provider_response_body`] still surfaces
219            /// it while [`Self::provider_response_status`] returns `None`.
220            pub fn from_provider_body(body: impl Into<String>) -> Self {
221                Self::ProviderResponse(
222                    $crate::provider_response::ProviderResponseError::without_status(body),
223                )
224            }
225
226            /// Returns the raw provider response body when available.
227            ///
228            /// This is available for:
229            /// - `Self::ProviderResponse` using its preserved body.
230            /// - `Self::HttpError` when it wraps an HTTP non-success response that
231            ///   carries a body.
232            ///
233            /// Returns `None` for any other variant — for example a Rig-generated
234            /// `ProviderError` diagnostic, or a failure from a transport with no
235            /// provider response body to preserve. An empty preserved body is
236            /// reported as `Some("")` (the provider returned no payload), which is
237            /// distinct from `None`; note that [`Self::provider_response_json`]
238            /// maps that same empty body to `Ok(None)`.
239            pub fn provider_response_body(&self) -> Option<&str> {
240                match self {
241                    Self::ProviderResponse(response) => Some(response.body.as_str()),
242                    Self::HttpError(error) => error.non_success_body(),
243                    _ => None,
244                }
245            }
246
247            /// Parses the provider response body as JSON.
248            ///
249            /// Returns:
250            /// - `Ok(Some(value))` when a body is present and valid JSON.
251            /// - `Ok(None)` when no provider response body is available.
252            /// - `Err(error)` when a body is present but isn't valid JSON.
253            pub fn provider_response_json(
254                &self,
255            ) -> Result<Option<serde_json::Value>, serde_json::Error> {
256                $crate::provider_response::json(self.provider_response_body())
257            }
258
259            /// Returns the HTTP status code when this error preserves one, either
260            /// from a non-success HTTP response, from a preserved provider
261            /// response, or from a 2xx error envelope.
262            ///
263            /// **Warning:** this can return a **2xx** status. Some providers send
264            /// an error envelope alongside a success status, which Rig preserves
265            /// via [`Self::ProviderResponse`]. Callers must not infer failure from
266            /// the status code alone — the existence of this error already means
267            /// the call failed. Returns `None` for non-HTTP transports (gRPC / SDK
268            /// clients) and for variants that carry no provider response.
269            pub fn provider_response_status(&self) -> Option<http::StatusCode> {
270                match self {
271                    Self::ProviderResponse(response) => response.status,
272                    Self::HttpError(error) => error.non_success_status(),
273                    _ => None,
274                }
275            }
276
277            /// Returns the provider's transport request id for the failed
278            /// call, when the capture path preserved one (rig#2314) — the id
279            /// provider support asks for. `None` for providers that report
280            /// none, for paths that captured no transport metadata, and for
281            /// errors with no provider response at all.
282            pub fn provider_request_id(&self) -> Option<&str> {
283                match self {
284                    Self::ProviderResponse(response) => response.provider_request_id.as_deref(),
285                    _ => None,
286                }
287            }
288
289            /// Returns the response's headers when the capture path preserved
290            /// them (rig#2210) — the rate-limit metadata (`Retry-After`,
291            /// `x-ratelimit-*`) a caller needs to back off correctly:
292            ///
293            /// ```no_run
294            /// # use rig_core::completion::CompletionError;
295            /// # use std::time::Duration;
296            /// fn backoff(error: &CompletionError) -> Option<Duration> {
297            ///     let seconds = error
298            ///         .provider_response_headers()?
299            ///         .get(http::header::RETRY_AFTER)?
300            ///         .to_str()
301            ///         .ok()?
302            ///         .parse()
303            ///         .ok()?;
304            ///     Some(Duration::from_secs(seconds))
305            /// }
306            /// ```
307            ///
308            /// Returns `None` when no headers were captured: non-HTTP
309            /// transports (gRPC / SDK clients), Rig-generated diagnostics,
310            /// errors funnelled from only a status and body (e.g. via
311            /// [`Self::from_http_response`]), and transports that report a
312            /// non-success status without preserving them. `None` therefore
313            /// means "not captured", never "the response had no headers".
314            pub fn provider_response_headers(&self) -> Option<&http::HeaderMap> {
315                match self {
316                    Self::ProviderResponse(response) => response.headers.as_deref(),
317                    Self::HttpError(error) => error.non_success_headers(),
318                    _ => None,
319                }
320            }
321        }
322    };
323}
324
325pub(crate) use impl_provider_response_helpers;
326
327/// Implements the shared response-metadata setters (`with_message_id`,
328/// `with_response_id`, `with_provider_request_id`, `with_model`, `with_raw`
329/// and their `_optional` forms) on a response type with `message_id`,
330/// `response_id`, `provider_request_id`, and `model` fields of type
331/// `Option<String>` and a `raw` field of type `serde_json::Value`.
332///
333/// An empty string is treated as absent: gateways that echo `""` for fields
334/// they don't populate must not produce a `Some("")` that differs between the
335/// buffered and streaming paths. The invariant lives in these generated
336/// setters so no provider call site can diverge. `finish_reason` handling is
337/// intentionally left to each type, since reconciliation rules differ.
338///
339/// `raw` is not an identifier, but it belongs here for the same reason the
340/// identifiers do: it is per-attempt metadata that both surfaces observe —
341/// the unary response and the streaming terminal record carry the same field
342/// with the same meaning, populated at the provider seams from one setter, so
343/// neither surface can grow a variant the other lacks.
344macro_rules! response_metadata_setters {
345    ($ty:ty) => {
346        impl $ty {
347            /// Attach the provider-assigned message ID.
348            ///
349            /// An empty string is treated as absent: gateways that echo `""`
350            /// for fields they don't populate must not produce a `Some("")`
351            /// that differs between the buffered and streaming paths. All
352            /// identifier and model setters share this rule so the invariant
353            /// lives here rather than at every provider call site.
354            pub fn with_message_id(self, message_id: impl Into<String>) -> Self {
355                self.with_optional_message_id(Some(message_id.into()))
356            }
357
358            /// Attach the provider-assigned message ID when the provider
359            /// reported one.
360            pub fn with_optional_message_id(
361                mut self,
362                message_id: Option<impl Into<String>>,
363            ) -> Self {
364                self.message_id = message_id.map(Into::into).filter(|id| !id.is_empty());
365                self
366            }
367
368            /// Attach the provider-assigned response-scoped ID.
369            pub fn with_response_id(self, response_id: impl Into<String>) -> Self {
370                self.with_optional_response_id(Some(response_id.into()))
371            }
372
373            /// Attach the provider-assigned response-scoped ID when the
374            /// provider reported one.
375            pub fn with_optional_response_id(
376                mut self,
377                response_id: Option<impl Into<String>>,
378            ) -> Self {
379                self.response_id = response_id.map(Into::into).filter(|id| !id.is_empty());
380                self
381            }
382
383            /// Attach the provider's transport-level request identifier.
384            pub fn with_provider_request_id(self, request_id: impl Into<String>) -> Self {
385                self.with_optional_provider_request_id(Some(request_id.into()))
386            }
387
388            /// Attach the provider's transport-level request identifier when
389            /// the provider reported one.
390            pub fn with_optional_provider_request_id(
391                mut self,
392                request_id: Option<impl Into<String>>,
393            ) -> Self {
394                self.provider_request_id = request_id.map(Into::into).filter(|id| !id.is_empty());
395                self
396            }
397
398            /// Attach the provider-reported model identifier.
399            ///
400            /// An empty string is treated as absent, matching the identifier
401            /// setters.
402            pub fn with_model(self, model: impl Into<String>) -> Self {
403                self.with_optional_model(Some(model.into()))
404            }
405
406            /// Attach the provider-reported model identifier when the
407            /// response carried one.
408            pub fn with_optional_model(mut self, model: Option<impl Into<String>>) -> Self {
409                self.model = model.map(Into::into).filter(|model| !model.is_empty());
410                self
411            }
412
413            /// Attach the provider's own response, serialized — the value the
414            /// model's inherent raw method would have returned. Every provider
415            /// seam calls this; see the `raw` field for the exact meaning of
416            /// the payload (and of `Value::Null`).
417            pub fn with_raw(mut self, raw: impl Into<serde_json::Value>) -> Self {
418                self.raw = raw.into();
419                self
420            }
421        }
422    };
423}
424
425pub(crate) use response_metadata_setters;
426
427/// Declares a capability error enum with the shared core variants
428/// (`HttpError`, `JsonError`, `ResponseError`, `ProviderError`,
429/// `ProviderResponse`) and wires up [`impl_provider_response_helpers!`] for
430/// it, so the five modality errors stay structurally identical.
431///
432/// `$noun` names the capability in the generated docs (e.g. `"transcription"`
433/// → "Error returned by the transcription model provider"). The first brace
434/// block is spliced between `JsonError` and `ResponseError` (request-building
435/// and URL errors live there); the optional second block is spliced before
436/// `ProviderError` for capability-specific variants.
437macro_rules! provider_error_enum {
438    (
439        $(#[$extra_doc:meta])*
440        $name:ident, $noun:literal {
441            $($mid_variants:tt)*
442        }
443        $({ $($late_variants:tt)* })?
444    ) => {
445        #[doc = concat!("Errors returned by ", $noun, " models.")]
446        ///
447        /// Inspect provider failures with [`Self::provider_response_body`],
448        /// [`Self::provider_response_json`], and [`Self::provider_response_status`].
449        $(#[$extra_doc])*
450        #[derive(Debug, thiserror::Error)]
451        pub enum $name {
452            /// Http error (e.g.: connection error, timeout, etc.)
453            #[error("HttpError: {0}")]
454            HttpError(#[from] $crate::http_client::Error),
455
456            /// Json error (e.g.: serialization, deserialization)
457            #[error("JsonError: {0}")]
458            JsonError(#[from] serde_json::Error),
459
460            $($mid_variants)*
461
462            #[doc = concat!("Error parsing the ", $noun, " response")]
463            #[error("ResponseError: {0}")]
464            ResponseError(String),
465
466            $($($late_variants)*)?
467
468            #[doc = concat!("Error returned by the ", $noun, " model provider")]
469            #[error("ProviderError: {0}")]
470            ProviderError(String),
471
472            #[doc = concat!("Raw error response preserved from the ", $noun, " model provider")]
473            #[error("ProviderResponseError: {0}")]
474            ProviderResponse($crate::provider_response::ProviderResponseError),
475        }
476
477        $crate::provider_response::impl_provider_response_helpers!($name);
478    };
479}
480
481pub(crate) use provider_error_enum;
482
483#[cfg(test)]
484mod tests {
485    use http::StatusCode;
486
487    /// Asserts the shared funnel preserves a provider's status + body across the
488    /// three routes every capability error exposes: a non-success HTTP response,
489    /// a 2xx provider error envelope, and a non-HTTP (gRPC/SDK) transport.
490    macro_rules! assert_funnel {
491        ($err:ty) => {{
492            let body = r#"{"error":{"message":"boom"}}"#;
493
494            // Non-success status -> HttpError, with status + body recoverable.
495            let err = <$err>::from_http_response(StatusCode::SERVICE_UNAVAILABLE, body);
496            assert_eq!(
497                err.provider_response_status(),
498                Some(StatusCode::SERVICE_UNAVAILABLE),
499                concat!(stringify!($err), ": non-success status not preserved"),
500            );
501            assert_eq!(
502                err.provider_response_body(),
503                Some(body),
504                concat!(stringify!($err), ": non-success body not preserved"),
505            );
506            assert_eq!(
507                err.provider_response_json()
508                    .expect("valid json")
509                    .expect("present json")["error"]["message"],
510                "boom",
511            );
512
513            // A provider error envelope returned with a 2xx status -> ProviderResponse,
514            // preserving the (success) status so callers can still see it.
515            let err = <$err>::from_http_response(StatusCode::OK, body);
516            assert_eq!(
517                err.provider_response_status(),
518                Some(StatusCode::OK),
519                concat!(stringify!($err), ": 2xx envelope status not preserved"),
520            );
521            assert_eq!(err.provider_response_body(), Some(body));
522
523            // No HTTP status available (gRPC/SDK) -> ProviderResponse with status None.
524            let err = <$err>::from_provider_body(body);
525            assert_eq!(
526                err.provider_response_status(),
527                None,
528                concat!(
529                    stringify!($err),
530                    ": status should be None for provider body"
531                ),
532            );
533            assert_eq!(err.provider_response_body(), Some(body));
534
535            // Empty-body asymmetry: the body is `Some("")` but JSON parses to `Ok(None)`.
536            let err = <$err>::from_provider_body("");
537            assert_eq!(err.provider_response_body(), Some(""));
538            assert!(err.provider_response_json().expect("ok").is_none());
539
540            // rig#2210 — headers are only present when a capture path
541            // preserved them. The status+body funnels never have any...
542            for err in [
543                <$err>::from_http_response(StatusCode::TOO_MANY_REQUESTS, body),
544                <$err>::from_http_response(StatusCode::OK, body),
545                <$err>::from_provider_body(body),
546                <$err>::from_http_response_with_request_id(
547                    StatusCode::TOO_MANY_REQUESTS,
548                    body,
549                    Some("req_abc".to_string()),
550                ),
551            ] {
552                assert!(
553                    err.provider_response_headers().is_none(),
554                    concat!(stringify!($err), ": a funnel cannot invent headers"),
555                );
556                // ...and attaching `None` must not disturb the error.
557                let untouched = err.with_response_headers(None);
558                assert!(untouched.provider_response_headers().is_none());
559                assert_eq!(untouched.provider_response_body(), Some(body));
560            }
561
562            // ...but both classifications carry headers once attached, so
563            // `Retry-After` stays readable on a 429 whether the provider has a
564            // request-id contract (ProviderResponse) or not (HttpError).
565            let contract_less = <$err>::from_http_response(StatusCode::TOO_MANY_REQUESTS, body)
566                .with_response_headers(Some(retry_after_headers()));
567            let contract = <$err>::from_http_response_with_request_id(
568                StatusCode::TOO_MANY_REQUESTS,
569                body,
570                Some("req_abc".to_string()),
571            )
572            .with_response_headers(Some(retry_after_headers()));
573
574            for (label, err) in [("contract-less", contract_less), ("contract", contract)] {
575                let err_ty = stringify!($err);
576                assert_eq!(
577                    err.provider_response_headers()
578                        .and_then(|headers| headers.get(http::header::RETRY_AFTER))
579                        .and_then(|value| value.to_str().ok()),
580                    Some("20"),
581                    "{err_ty}/{label}: captured Retry-After not surfaced",
582                );
583                // Attaching headers must not disturb the status or body the
584                // funnel already preserved.
585                assert_eq!(
586                    err.provider_response_status(),
587                    Some(StatusCode::TOO_MANY_REQUESTS),
588                    "{err_ty}/{label}: status lost when headers were attached",
589                );
590                assert_eq!(
591                    err.provider_response_body(),
592                    Some(body),
593                    "{err_ty}/{label}: body lost when headers were attached",
594                );
595            }
596        }};
597    }
598
599    /// A 429's rate-limit metadata, as a provider would send it.
600    fn retry_after_headers() -> Box<http::HeaderMap> {
601        let mut headers = http::HeaderMap::new();
602        headers.insert(
603            http::header::RETRY_AFTER,
604            http::HeaderValue::from_static("20"),
605        );
606        headers.insert("x-ratelimit-remaining", http::HeaderValue::from_static("0"));
607        Box::new(headers)
608    }
609
610    #[test]
611    fn funnel_preserves_status_and_body_for_every_capability_error() {
612        assert_funnel!(crate::completion::CompletionError);
613        assert_funnel!(crate::embeddings::embedding::EmbeddingError);
614        assert_funnel!(crate::transcription::TranscriptionError);
615        assert_funnel!(crate::client::verify::VerifyError);
616        assert_funnel!(crate::rerank::RerankError);
617        #[cfg(feature = "image")]
618        assert_funnel!(crate::image_generation::ImageGenerationError);
619        #[cfg(feature = "audio")]
620        assert_funnel!(crate::audio_generation::AudioGenerationError);
621    }
622
623    /// rig#2314: the metadata-aware funnel preserves non-success statuses as
624    /// `ProviderResponse` so the transport id has a home; status, body, and
625    /// id all stay recoverable, and the id appears in the logged message.
626    #[test]
627    fn with_request_id_funnel_preserves_non_success_as_provider_response() {
628        let error = crate::completion::CompletionError::from_http_response_with_request_id(
629            StatusCode::NOT_FOUND,
630            r#"{"error":"nope"}"#,
631            Some("req_abc".to_string()),
632        );
633        assert!(matches!(
634            error,
635            crate::completion::CompletionError::ProviderResponse(_)
636        ));
637        assert_eq!(
638            error.provider_response_status(),
639            Some(StatusCode::NOT_FOUND)
640        );
641        assert_eq!(error.provider_response_body(), Some(r#"{"error":"nope"}"#));
642        assert_eq!(error.provider_request_id(), Some("req_abc"));
643        assert!(
644            error.to_string().contains("request id: req_abc"),
645            "the id support asks for appears in the message: {error}"
646        );
647    }
648
649    /// A missing id is `None`, never a secondary failure, and leaves the
650    /// message unchanged.
651    #[test]
652    fn with_request_id_funnel_tolerates_absent_id() {
653        let error = crate::completion::CompletionError::from_http_response_with_request_id(
654            StatusCode::BAD_REQUEST,
655            "bad",
656            None,
657        );
658        assert_eq!(error.provider_request_id(), None);
659        assert!(!error.to_string().contains("request id"));
660    }
661
662    /// The metadata-less funnel's classification is untouched: non-success
663    /// stays transport-shaped, and its accessor reports no id.
664    #[test]
665    fn metadata_less_funnel_classification_is_unchanged() {
666        let error =
667            crate::completion::CompletionError::from_http_response(StatusCode::BAD_REQUEST, "bad");
668        assert!(matches!(
669            error,
670            crate::completion::CompletionError::HttpError(_)
671        ));
672        assert_eq!(error.provider_request_id(), None);
673    }
674
675    /// rig#2210 × rig#2314: the two pieces of transport metadata are captured
676    /// on the same path and must not evict each other.
677    #[test]
678    fn request_id_and_headers_coexist_on_one_error() {
679        let error = crate::completion::CompletionError::from_http_response_with_request_id(
680            StatusCode::TOO_MANY_REQUESTS,
681            r#"{"error":"slow down"}"#,
682            Some("req_abc".to_string()),
683        )
684        .with_response_headers(Some(retry_after_headers()));
685
686        assert_eq!(error.provider_request_id(), Some("req_abc"));
687        assert_eq!(
688            error
689                .provider_response_headers()
690                .and_then(|headers| headers.get("x-ratelimit-remaining"))
691                .and_then(|value| value.to_str().ok()),
692            Some("0"),
693        );
694    }
695
696    /// Attaching headers to a contract-less non-success error upgrades the
697    /// transport variant in place, leaving the classification callers match on
698    /// (`HttpError`) and the preserved status/body untouched.
699    #[test]
700    fn attaching_headers_upgrades_the_transport_variant_in_place() {
701        let error = crate::completion::CompletionError::from_http_response(
702            StatusCode::TOO_MANY_REQUESTS,
703            "slow down",
704        )
705        .with_response_headers(Some(retry_after_headers()));
706
707        assert!(matches!(
708            error,
709            crate::completion::CompletionError::HttpError(
710                crate::http_client::Error::InvalidStatusCodeWithDetails { .. }
711            ),
712        ));
713        assert_eq!(
714            error.provider_response_status(),
715            Some(StatusCode::TOO_MANY_REQUESTS)
716        );
717        assert_eq!(error.provider_response_body(), Some("slow down"));
718        // The contract-less path reports no id whether or not headers rode along.
719        assert_eq!(error.provider_request_id(), None);
720    }
721
722    /// First capture wins on both classifications: the site that saw the
723    /// response is the authority, and a later attach only fills a gap. Without
724    /// this, a wrapper that re-attaches would silently replace the real
725    /// response's headers.
726    #[test]
727    fn attaching_headers_never_overwrites_an_earlier_capture() {
728        let mut later = http::HeaderMap::new();
729        later.insert(http::header::RETRY_AFTER, "999".parse().expect("value"));
730
731        for build in [
732            crate::completion::CompletionError::from_http_response,
733            |status, body| {
734                crate::completion::CompletionError::from_http_response_with_request_id(
735                    status,
736                    body,
737                    Some("req_abc".to_string()),
738                )
739            },
740        ] {
741            let error = build(StatusCode::TOO_MANY_REQUESTS, "slow down")
742                .with_response_headers(Some(retry_after_headers()))
743                .with_response_headers(Some(Box::new(later.clone())));
744
745            assert_eq!(
746                error
747                    .provider_response_headers()
748                    .and_then(|headers| headers.get(http::header::RETRY_AFTER))
749                    .and_then(|value| value.to_str().ok()),
750                Some("20"),
751                "the first capture must win",
752            );
753        }
754    }
755
756    /// Variants with no slot for a response absorb the call unchanged, so a
757    /// capture site can attach unconditionally.
758    #[test]
759    fn attaching_headers_to_a_slotless_variant_is_a_no_op() {
760        let error = crate::completion::CompletionError::ProviderError("rig diagnostic".to_string())
761            .with_response_headers(Some(retry_after_headers()));
762
763        assert!(matches!(
764            error,
765            crate::completion::CompletionError::ProviderError(_)
766        ));
767        assert!(error.provider_response_headers().is_none());
768        assert_eq!(error.to_string(), "ProviderError: rig diagnostic");
769    }
770
771    /// Display goldens (rig#2315 error matrix): error strings are what
772    /// callers grep and alert on — message churn must be a reviewed diff.
773    #[test]
774    fn display_goldens_for_error_shapes() {
775        let with_id = crate::completion::CompletionError::from_http_response_with_request_id(
776            StatusCode::NOT_FOUND,
777            r#"{"error":"nope"}"#,
778            Some("req_abc".to_string()),
779        );
780        assert_eq!(
781            with_id.to_string(),
782            r#"ProviderResponseError: status 404 Not Found: {"error":"nope"} (request id: req_abc)"#
783        );
784
785        let without_id = crate::completion::CompletionError::from_http_response_with_request_id(
786            StatusCode::NOT_FOUND,
787            r#"{"error":"nope"}"#,
788            None,
789        );
790        assert_eq!(
791            without_id.to_string(),
792            r#"ProviderResponseError: status 404 Not Found: {"error":"nope"}"#
793        );
794
795        let contract_less = crate::completion::CompletionError::from_http_response(
796            StatusCode::NOT_FOUND,
797            r#"{"error":"nope"}"#,
798        );
799        assert_eq!(
800            contract_less.to_string(),
801            r#"HttpError: Invalid status code 404 Not Found with message: {"error":"nope"}"#
802        );
803
804        // The two transport variants display identically.
805        let details = crate::http_client::Error::InvalidStatusCodeWithDetails {
806            status: StatusCode::NOT_FOUND,
807            body: "x".to_string(),
808            headers: Box::new(http::HeaderMap::new()),
809        };
810        let message = crate::http_client::Error::InvalidStatusCodeWithMessage(
811            StatusCode::NOT_FOUND,
812            "x".to_string(),
813        );
814        assert_eq!(details.to_string(), message.to_string());
815
816        // rig#2210: capturing headers must never change the text a caller
817        // logs, on either classification.
818        for build in [
819            crate::completion::CompletionError::from_http_response,
820            |status, body| {
821                crate::completion::CompletionError::from_http_response_with_request_id(
822                    status,
823                    body,
824                    Some("req_abc".to_string()),
825                )
826            },
827        ] {
828            let bare = build(StatusCode::TOO_MANY_REQUESTS, r#"{"error":"slow down"}"#);
829            let bare_text = bare.to_string();
830            let with_headers = build(StatusCode::TOO_MANY_REQUESTS, r#"{"error":"slow down"}"#)
831                .with_response_headers(Some(retry_after_headers()));
832            assert_eq!(with_headers.to_string(), bare_text);
833        }
834    }
835}