Skip to main content

floopy/
error.rs

1//! The [`Error`] hierarchy. One variant per failure mode the gateway can
2//! signal, plus transport-level timeout/connection errors. Mirrors the
3//! `FloopyError` hierarchy in the Node/Python/Go SDKs.
4//!
5//! Errors raised by the OpenAI-compatible surface (chat/embeddings/models)
6//! come from the underlying [`async_openai`] crate
7//! ([`async_openai::error::OpenAIError`]), *not* this type.
8
9use std::fmt;
10
11/// Structured detail attached to every gateway-originated [`Error`]. It is
12/// always boxed inside the [`Error`] variants to keep the error type small.
13#[derive(Debug, Clone)]
14pub struct ErrorDetails {
15    /// Human-readable message (gateway-provided when available, otherwise
16    /// `"HTTP <status>"`).
17    pub message: String,
18    /// HTTP status code, when the error originated from a response.
19    pub status: Option<u16>,
20    /// Gateway error code, when present.
21    pub code: Option<String>,
22    /// Value of the `X-Request-Id` response header, when present.
23    pub request_id: Option<String>,
24    /// The plan capability the request needed (on a [`Error::Plan`]).
25    pub feature: Option<String>,
26    /// `Retry-After` value in seconds (on a [`Error::RateLimit`]).
27    pub retry_after_seconds: Option<u64>,
28    /// Parsed error body, when present.
29    pub body: Option<serde_json::Value>,
30}
31
32impl fmt::Display for ErrorDetails {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        write!(f, "{}", self.message)?;
35        if let Some(status) = self.status {
36            write!(f, " (status={status}")?;
37            if let Some(rid) = &self.request_id {
38                write!(f, " request_id={rid}")?;
39            }
40            write!(f, ")")?;
41        }
42        Ok(())
43    }
44}
45
46/// Every error returned by a Floopy-only resource.
47#[derive(Debug, thiserror::Error)]
48#[non_exhaustive]
49pub enum Error {
50    /// HTTP 401, or 403 without a `feature` field.
51    #[error("authentication failed: {0}")]
52    Auth(Box<ErrorDetails>),
53
54    /// HTTP 403 with a `feature` field: the current plan does not include
55    /// the requested capability (see [`ErrorDetails::feature`]).
56    #[error("plan does not allow this feature: {0}")]
57    Plan(Box<ErrorDetails>),
58
59    /// HTTP 429 (see [`ErrorDetails::retry_after_seconds`]).
60    #[error("rate limit exceeded: {0}")]
61    RateLimit(Box<ErrorDetails>),
62
63    /// HTTP 400.
64    #[error("invalid request: {0}")]
65    Validation(Box<ErrorDetails>),
66
67    /// HTTP 404.
68    #[error("not found: {0}")]
69    NotFound(Box<ErrorDetails>),
70
71    /// HTTP 409.
72    #[error("conflict: {0}")]
73    Conflict(Box<ErrorDetails>),
74
75    /// HTTP 5xx.
76    #[error("gateway error: {0}")]
77    Server(Box<ErrorDetails>),
78
79    /// Any other non-2xx response.
80    #[error("api error: {0}")]
81    Api(Box<ErrorDetails>),
82
83    /// The request exceeded its deadline.
84    #[error("request timed out: {0}")]
85    Timeout(String),
86
87    /// A network failure talking to the gateway.
88    #[error("connection error: {0}")]
89    Connection(#[source] reqwest::Error),
90
91    /// A 2xx response whose body could not be decoded.
92    #[error("failed to decode response: {0}")]
93    Decode(String),
94
95    /// Invalid client configuration (e.g. an empty API key).
96    #[error("invalid configuration: {0}")]
97    Config(String),
98}
99
100impl Error {
101    /// The structured detail for gateway-originated errors, if any.
102    #[must_use]
103    pub fn details(&self) -> Option<&ErrorDetails> {
104        match self {
105            Error::Auth(d)
106            | Error::Plan(d)
107            | Error::RateLimit(d)
108            | Error::Validation(d)
109            | Error::NotFound(d)
110            | Error::Conflict(d)
111            | Error::Server(d)
112            | Error::Api(d) => Some(d),
113            Error::Timeout(_) | Error::Connection(_) | Error::Decode(_) | Error::Config(_) => None,
114        }
115    }
116
117    /// The HTTP status code, when the error originated from a response.
118    #[must_use]
119    pub fn status(&self) -> Option<u16> {
120        self.details().and_then(|d| d.status)
121    }
122
123    /// The `X-Request-Id` of the failed request, when present.
124    #[must_use]
125    pub fn request_id(&self) -> Option<&str> {
126        self.details().and_then(|d| d.request_id.as_deref())
127    }
128
129    /// The plan capability the request needed, for [`Error::Plan`].
130    #[must_use]
131    pub fn feature(&self) -> Option<&str> {
132        self.details().and_then(|d| d.feature.as_deref())
133    }
134
135    /// The `Retry-After` hint in seconds, for [`Error::RateLimit`].
136    #[must_use]
137    pub fn retry_after_seconds(&self) -> Option<u64> {
138        self.details().and_then(|d| d.retry_after_seconds)
139    }
140}
141
142/// Map an HTTP status + parsed error body to the right [`Error`]. The
143/// gateway returns `{"error": {"code", "message", "feature"}}`; a plain
144/// body is preserved on [`ErrorDetails::body`] and surfaced generically.
145pub(crate) fn from_status(
146    status: u16,
147    body: Option<serde_json::Value>,
148    request_id: Option<String>,
149    retry_after_seconds: Option<u64>,
150) -> Error {
151    let err_obj = body.as_ref().and_then(|b| b.get("error"));
152    let message = err_obj
153        .and_then(|e| e.get("message"))
154        .and_then(|m| m.as_str())
155        .map(str::to_owned)
156        .unwrap_or_else(|| format!("HTTP {status}"));
157    let code = err_obj
158        .and_then(|e| e.get("code"))
159        .and_then(|c| c.as_str())
160        .map(str::to_owned);
161    let feature = err_obj
162        .and_then(|e| e.get("feature"))
163        .and_then(|f| f.as_str())
164        .map(str::to_owned);
165
166    let details = Box::new(ErrorDetails {
167        message,
168        status: Some(status),
169        code,
170        request_id,
171        feature: feature.clone(),
172        retry_after_seconds,
173        body,
174    });
175
176    match status {
177        400 => Error::Validation(details),
178        401 => Error::Auth(details),
179        403 if feature.is_some() => Error::Plan(details),
180        403 => Error::Auth(details),
181        404 => Error::NotFound(details),
182        409 => Error::Conflict(details),
183        429 => Error::RateLimit(details),
184        s if s >= 500 => Error::Server(details),
185        _ => Error::Api(details),
186    }
187}
188
189/// Convenience alias for results returned by Floopy-only resources.
190pub type Result<T> = std::result::Result<T, Error>;