crimson_crab/error.rs
1//! Errors returned by the SDK.
2//!
3//! Every fallible operation returns [`Result<T>`], whose error type is [`Error`].
4//! HTTP responses that are not `2xx` are mapped to a per-status [`Error`]
5//! variant carrying a structured [`ApiError`] (the API's error envelope plus the
6//! captured `request-id`). Transport failures, timeouts, (de)serialization
7//! problems, and configuration mistakes have their own variants.
8
9use std::time::Duration;
10
11use serde::Deserialize;
12
13/// A convenient alias for a [`std::result::Result`] whose error is [`Error`].
14///
15/// # Examples
16///
17/// ```
18/// fn parse(id: &str) -> crimson_crab::Result<u32> {
19/// id.parse().map_err(|_| crimson_crab::Error::Config("bad id".into()))
20/// }
21/// assert!(parse("abc").is_err());
22/// ```
23pub type Result<T> = std::result::Result<T, Error>;
24
25/// The structured body of an API error.
26///
27/// `error_type` and `message` come from the wire envelope
28/// (`{"type":"error","error":{"type":..., "message":...}}`); `request_id` is
29/// captured from the `request-id` response header (falling back to the
30/// envelope's `request_id` field) so it can be quoted when reporting an issue.
31///
32/// # Examples
33///
34/// ```
35/// use crimson_crab::ApiError;
36///
37/// let err = ApiError {
38/// error_type: "invalid_request_error".to_string(),
39/// message: "max_tokens is required".to_string(),
40/// request_id: Some("req_123".to_string()),
41/// };
42/// assert_eq!(err.error_type, "invalid_request_error");
43/// ```
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct ApiError {
46 /// The API's machine-readable error type, e.g. `"invalid_request_error"`.
47 pub error_type: String,
48 /// A human-readable description of what went wrong.
49 pub message: String,
50 /// The `request-id` associated with the failed request, if any.
51 pub request_id: Option<String>,
52}
53
54impl std::fmt::Display for ApiError {
55 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56 write!(f, "{} ({})", self.message, self.error_type)?;
57 if let Some(request_id) = &self.request_id {
58 write!(f, " [request-id: {request_id}]")?;
59 }
60 Ok(())
61 }
62}
63
64/// All errors the SDK can return.
65///
66/// The per-status variants (`BadRequest`, `Authentication`, …) mirror the
67/// documented HTTP error codes; any other non-2xx status maps to
68/// [`Error::Api`]. Use [`Error::is_retryable`] to decide whether an error is
69/// worth retrying (the client already retries automatically up to
70/// `max_retries`).
71///
72/// # Examples
73///
74/// ```
75/// use crimson_crab::{ApiError, Error};
76///
77/// let err = Error::Overloaded(ApiError {
78/// error_type: "overloaded_error".into(),
79/// message: "overloaded".into(),
80/// request_id: None,
81/// });
82/// assert!(err.is_retryable());
83/// ```
84#[derive(Debug, thiserror::Error)]
85#[non_exhaustive]
86pub enum Error {
87 /// `400 Bad Request` — the request was malformed or invalid.
88 #[error("bad request (400): {0}")]
89 BadRequest(ApiError),
90 /// `401 Unauthorized` — the API key is missing or invalid.
91 #[error("authentication error (401): {0}")]
92 Authentication(ApiError),
93 /// `403 Forbidden` — the key lacks permission (also `billing_error`).
94 #[error("permission denied (403): {0}")]
95 PermissionDenied(ApiError),
96 /// `404 Not Found` — the endpoint or resource does not exist.
97 #[error("not found (404): {0}")]
98 NotFound(ApiError),
99 /// `413 Payload Too Large` — the request exceeds size limits.
100 #[error("request too large (413): {0}")]
101 RequestTooLarge(ApiError),
102 /// `429 Too Many Requests` — rate limited; carries `retry-after` if sent.
103 #[error("rate limited (429): {err}")]
104 RateLimit {
105 /// The structured error body.
106 err: ApiError,
107 /// The server-suggested delay before retrying, from `retry-after`.
108 retry_after: Option<Duration>,
109 },
110 /// `529 Overloaded` — the API is temporarily overloaded.
111 #[error("overloaded (529): {0}")]
112 Overloaded(ApiError),
113 /// Any other non-2xx status not covered by a dedicated variant.
114 #[error("api error ({status}): {err}")]
115 Api {
116 /// The HTTP status code.
117 status: u16,
118 /// The structured error body.
119 err: ApiError,
120 },
121 /// The request timed out (retryable).
122 #[error("request timed out")]
123 Timeout,
124 /// A transport-level failure (DNS, TCP, TLS, …).
125 #[error(transparent)]
126 Connection(reqwest::Error),
127 /// A JSON (de)serialization failure.
128 #[error("JSON error: {0}")]
129 Serde(#[from] serde_json::Error),
130 /// A streaming decode error (e.g. a malformed batch-results line).
131 #[error("stream error: {0}")]
132 Stream(String),
133 /// A configuration error (e.g. a missing API key or invalid builder input).
134 #[error("configuration error: {0}")]
135 Config(String),
136}
137
138impl From<reqwest::Error> for Error {
139 fn from(err: reqwest::Error) -> Self {
140 if err.is_timeout() {
141 Error::Timeout
142 } else {
143 Error::Connection(err)
144 }
145 }
146}
147
148impl Error {
149 /// Returns `true` if retrying the operation might succeed.
150 ///
151 /// Retryable errors are rate limits, overloads, timeouts, connection
152 /// failures, and `408`/`409`/`>= 500` API errors.
153 ///
154 /// # Examples
155 ///
156 /// ```
157 /// use crimson_crab::{ApiError, Error};
158 ///
159 /// let bad = Error::BadRequest(ApiError {
160 /// error_type: "invalid_request_error".into(),
161 /// message: "nope".into(),
162 /// request_id: None,
163 /// });
164 /// assert!(!bad.is_retryable());
165 /// assert!(Error::Timeout.is_retryable());
166 /// ```
167 pub fn is_retryable(&self) -> bool {
168 match self {
169 Error::RateLimit { .. }
170 | Error::Overloaded(_)
171 | Error::Timeout
172 | Error::Connection(_) => true,
173 Error::Api { status, .. } => *status == 408 || *status == 409 || *status >= 500,
174 _ => false,
175 }
176 }
177
178 /// Returns the server-suggested retry delay, if the error carries one.
179 ///
180 /// Only [`Error::RateLimit`] can carry a `retry-after` value.
181 ///
182 /// # Examples
183 ///
184 /// ```
185 /// use std::time::Duration;
186 /// use crimson_crab::{ApiError, Error};
187 ///
188 /// let err = Error::RateLimit {
189 /// err: ApiError { error_type: "rate_limit_error".into(), message: "slow".into(), request_id: None },
190 /// retry_after: Some(Duration::from_secs(3)),
191 /// };
192 /// assert_eq!(err.retry_after(), Some(Duration::from_secs(3)));
193 /// assert_eq!(Error::Timeout.retry_after(), None);
194 /// ```
195 pub fn retry_after(&self) -> Option<Duration> {
196 match self {
197 Error::RateLimit { retry_after, .. } => *retry_after,
198 _ => None,
199 }
200 }
201}
202
203/// Maps an HTTP status and raw response body to the appropriate [`Error`].
204pub(crate) fn from_status(
205 status: u16,
206 body: &[u8],
207 request_id: Option<String>,
208 retry_after: Option<Duration>,
209) -> Error {
210 let api = parse_api_error(body, request_id);
211 status_to_error(status, api, retry_after)
212}
213
214/// Maps an in-stream `error` event body — which carries no HTTP status — to the
215/// most appropriate [`Error`] variant, inferring a status from the error type.
216pub(crate) fn from_error_body(error_type: String, message: String) -> Error {
217 let status = match error_type.as_str() {
218 "invalid_request_error" => 400,
219 "authentication_error" => 401,
220 "permission_error" | "billing_error" => 403,
221 "not_found_error" => 404,
222 "request_too_large" => 413,
223 "rate_limit_error" => 429,
224 "overloaded_error" => 529,
225 _ => 500,
226 };
227 let api = ApiError {
228 error_type,
229 message,
230 request_id: None,
231 };
232 status_to_error(status, api, None)
233}
234
235/// Maps a status code and parsed [`ApiError`] to the matching [`Error`] variant.
236fn status_to_error(status: u16, api: ApiError, retry_after: Option<Duration>) -> Error {
237 match status {
238 400 => Error::BadRequest(api),
239 401 => Error::Authentication(api),
240 403 => Error::PermissionDenied(api),
241 404 => Error::NotFound(api),
242 413 => Error::RequestTooLarge(api),
243 429 => Error::RateLimit {
244 err: api,
245 retry_after,
246 },
247 529 => Error::Overloaded(api),
248 _ => Error::Api { status, err: api },
249 }
250}
251
252/// Parses the error envelope, falling back to a raw-body message when the body
253/// is not the expected JSON shape.
254fn parse_api_error(body: &[u8], request_id: Option<String>) -> ApiError {
255 #[derive(Deserialize)]
256 struct Envelope {
257 error: EnvelopeBody,
258 #[serde(default)]
259 request_id: Option<String>,
260 }
261
262 #[derive(Deserialize)]
263 struct EnvelopeBody {
264 #[serde(rename = "type", default)]
265 error_type: String,
266 #[serde(default)]
267 message: String,
268 }
269
270 match serde_json::from_slice::<Envelope>(body) {
271 Ok(env) => ApiError {
272 error_type: if env.error.error_type.is_empty() {
273 "unknown_error".to_string()
274 } else {
275 env.error.error_type
276 },
277 message: if env.error.message.is_empty() {
278 String::from_utf8_lossy(body).trim().to_string()
279 } else {
280 env.error.message
281 },
282 request_id: request_id.or(env.request_id),
283 },
284 Err(_) => ApiError {
285 error_type: "unknown_error".to_string(),
286 message: String::from_utf8_lossy(body).trim().to_string(),
287 request_id,
288 },
289 }
290}