Skip to main content

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}