1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
//! Preserved provider error responses and their machine codes.
//!
//! ```
//! use rig_core::ProviderResponseError;
//!
//! let reply = ProviderResponseError::new(http::StatusCode::TOO_MANY_REQUESTS, "slow down");
//! assert!(reply.is_retryable());
//! ```
use http::StatusCode;
/// A raw provider error body with captured transport metadata.
/// Callers must supply the provider's actual payload, not a generated diagnostic.
/// Serialization omits headers and route; deserialization restores them as `None`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ProviderResponseError {
/// HTTP status of the provider response, when it was captured alongside the body.
pub status: Option<StatusCode>,
/// Raw response body as returned by the provider.
pub body: String,
/// Transport request ID from response headers or SDK metadata, when captured.
pub provider_request_id: Option<String>,
/// Captured response headers, including rate-limit metadata. `None` means
/// not captured, rather than an empty header set. Omitted during serialization.
pub headers: Option<http::HeaderMap>,
/// The provider's own machine-readable code for the failure, when the
/// transport reported one apart from the body: a gRPC status code name
/// (`UNAVAILABLE`), an AWS exception type (`ThrottlingException`).
/// `None` when the reply carried only a status and a body.
pub code: Option<String>,
/// Transport retry verdict used when status is absent or successful.
/// A non-success HTTP status takes precedence; refusals are never retryable.
pub transient: Option<bool>,
/// Whether this error represents a content refusal. Refusals are never
/// retryable; refusal content in a successful model answer is separate.
pub refusal: bool,
/// The provider and request path the reply answered, when the operation
/// records them for diagnostics (model listing does). Omitted during
/// serialization.
pub route: Option<String>,
}
impl ProviderResponseError {
/// Preserve a provider error response captured with its HTTP status.
pub fn new(status: StatusCode, body: impl Into<String>) -> Self {
Self {
status: Some(status),
body: body.into(),
provider_request_id: None,
headers: None,
code: None,
transient: None,
refusal: false,
route: None,
}
}
/// Preserve a provider error body that has no HTTP status (gRPC / SDK
/// transports).
pub fn without_status(body: impl Into<String>) -> Self {
Self {
status: None,
body: body.into(),
provider_request_id: None,
headers: None,
code: None,
transient: None,
refusal: false,
route: None,
}
}
/// Mark the reply as the provider's verdict on the content: a refusal,
/// final, never retried.
pub fn with_refusal(mut self, refusal: bool) -> Self {
self.refusal = refusal;
self
}
/// Attach the HTTP status a transport reported beside a reply that was
/// first preserved without one (an SDK that hands back the raw HTTP
/// response next to its typed exception). A status already set is kept.
pub fn with_status(mut self, status: Option<StatusCode>) -> Self {
if self.status.is_none() {
self.status = status;
}
self
}
/// Attach the provider's own machine-readable code for the failure.
pub fn with_code(mut self, code: Option<String>) -> Self {
self.code = code.filter(|code| !code.is_empty());
self
}
/// Replaces the transport retry verdict. Used for absent or successful
/// HTTP statuses unless the response is a refusal.
pub fn with_transient(mut self, transient: Option<bool>) -> Self {
self.transient = transient;
self
}
/// Returns false for refusals. Otherwise classifies non-success HTTP statuses
/// through [`crate::error::retryable_status`], or uses `transient` for absent
/// or successful statuses. Missing verdicts default to false.
pub fn is_retryable(&self) -> bool {
if self.refusal {
return false;
}
match self.status {
Some(status) if !status.is_success() => {
crate::error::retryable_status(Some(status.as_u16()))
}
_ => self.transient.unwrap_or(false),
}
}
/// Returns the explicit code, falling back to nonempty string fields
/// `error.code`, `error.status`, then `error.type` in the JSON body.
pub fn machine_code(&self) -> Option<String> {
self.code.clone().or_else(|| body_code(&self.body))
}
/// Attach the transport request id the failed response reported.
pub fn with_provider_request_id(mut self, request_id: Option<String>) -> Self {
self.provider_request_id = request_id.filter(|id| !id.is_empty());
self
}
/// Replaces captured response headers, including rate-limit metadata.
pub fn with_headers(mut self, headers: Option<http::HeaderMap>) -> Self {
self.headers = headers;
self
}
}
impl std::fmt::Display for ProviderResponseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self.status {
Some(status) => write!(f, "status {status}: {}", self.body)?,
None => write!(f, "{}", self.body)?,
}
// The id support asks for belongs in the message a caller logs.
if let Some(request_id) = &self.provider_request_id {
write!(f, " (request id: {request_id})")?;
}
if let Some(route) = &self.route {
write!(f, " [{route}]")?;
}
Ok(())
}
}
impl std::error::Error for ProviderResponseError {}
/// Serialized error metadata with numeric HTTP status and no headers.
/// Volatile transport headers are excluded to keep replay records stable.
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct ProviderResponseErrorWire {
status: Option<u16>,
body: String,
provider_request_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
code: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
transient: Option<bool>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
refusal: bool,
}
impl serde::Serialize for ProviderResponseError {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
ProviderResponseErrorWire {
status: self.status.map(|status| status.as_u16()),
body: self.body.clone(),
provider_request_id: self.provider_request_id.clone(),
code: self.code.clone(),
transient: self.transient,
refusal: self.refusal,
}
.serialize(serializer)
}
}
impl<'de> serde::Deserialize<'de> for ProviderResponseError {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
use serde::de::Error as _;
let wire = ProviderResponseErrorWire::deserialize(deserializer)?;
let status = wire
.status
.map(StatusCode::from_u16)
.transpose()
.map_err(D::Error::custom)?;
Ok(Self {
status,
body: wire.body,
provider_request_id: wire.provider_request_id,
headers: None,
code: wire.code,
transient: wire.transient,
refusal: wire.refusal,
route: None,
})
}
}
/// Returns the first nonempty string at `error.code`, `error.status`, or
/// `error.type`, in that order. Invalid JSON, non-string fields, and missing
/// envelopes yield no code.
pub fn body_code(body: &str) -> Option<String> {
let value: serde_json::Value = serde_json::from_str(body).ok()?;
let error = value.get("error")?;
["code", "status", "type"].iter().find_map(|field| {
error
.get(field)
.and_then(serde_json::Value::as_str)
.filter(|code| !code.is_empty())
.map(str::to_owned)
})
}
/// An id or model name as a response carries it: an empty one is none.
pub(crate) fn reported(id: Option<String>) -> Option<String> {
id.filter(|id| !id.is_empty())
}
/// Parses an optional response body as JSON.
///
/// Returns:
/// - `Ok(Some(value))` when a body is present and valid JSON.
/// - `Ok(None)` when the body is absent or empty.
/// - `Err(error)` when a body is present but isn't valid JSON.
pub(crate) fn json(body: Option<&str>) -> Result<Option<serde_json::Value>, serde_json::Error> {
body.filter(|body| !body.is_empty())
.map(serde_json::from_str)
.transpose()
}
#[cfg(test)]
mod tests;