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}