Skip to main content

ironflow_sdk/
error.rs

1//! SDK error types.
2
3use ironflow_types::ErrorEnvelope;
4use serde::Deserialize;
5use thiserror::Error;
6
7/// Wrapper for the API error envelope.
8#[derive(Debug, Clone, Deserialize)]
9pub(crate) struct ApiErrorEnvelope {
10    pub error: ErrorEnvelope,
11}
12
13/// SDK error type.
14///
15/// Distinguishes between network errors, API errors (with status code and
16/// structured body), and deserialization errors.
17///
18/// # Examples
19///
20/// ```
21/// use ironflow_sdk::Error;
22///
23/// let err = Error::api(404, "RUN_NOT_FOUND", "run not found");
24/// assert!(err.is_api_error());
25/// ```
26#[derive(Debug, Error)]
27pub enum Error {
28    /// HTTP transport error (network unreachable, DNS, TLS, timeout).
29    #[error("HTTP error: {0}")]
30    Http(#[from] reqwest::Error),
31
32    /// The API returned an error response with a structured body.
33    #[error("API error {status}: [{code}] {message}")]
34    Api {
35        /// HTTP status code.
36        status: u16,
37        /// Machine-readable error code.
38        code: String,
39        /// Human-readable error message.
40        message: String,
41    },
42
43    /// Failed to deserialize the response body.
44    #[error("deserialization error: {0}")]
45    Deserialize(String),
46
47    /// SSE stream error.
48    #[error("SSE error: {0}")]
49    Sse(String),
50
51    /// All retry attempts exhausted.
52    #[error("retries exhausted after {attempts} attempts: {source}")]
53    Exhausted {
54        /// Number of attempts made.
55        attempts: u32,
56        /// The last error encountered.
57        source: Box<Error>,
58    },
59}
60
61impl Error {
62    /// Create an API error from its components.
63    pub fn api(status: u16, code: impl Into<String>, message: impl Into<String>) -> Self {
64        Self::Api {
65            status,
66            code: code.into(),
67            message: message.into(),
68        }
69    }
70
71    /// Returns `true` if this is an API error (not a transport error).
72    pub fn is_api_error(&self) -> bool {
73        matches!(self, Self::Api { .. })
74    }
75
76    /// Returns the HTTP status code if this is an API error (or an
77    /// [`Exhausted`](Self::Exhausted) wrapping one).
78    pub fn status(&self) -> Option<u16> {
79        match self {
80            Self::Api { status, .. } => Some(*status),
81            Self::Exhausted { source, .. } => source.status(),
82            _ => None,
83        }
84    }
85
86    /// Returns the API error code if this is an API error.
87    pub fn code(&self) -> Option<&str> {
88        match self {
89            Self::Api { code, .. } => Some(code),
90            _ => None,
91        }
92    }
93
94    /// Returns `true` if all retry attempts were exhausted.
95    pub fn is_exhausted(&self) -> bool {
96        matches!(self, Self::Exhausted { .. })
97    }
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn api_error_accessors() {
106        let err = Error::api(404, "NOT_FOUND", "not found");
107        assert!(err.is_api_error());
108        assert!(!err.is_exhausted());
109        assert_eq!(err.status(), Some(404));
110        assert_eq!(err.code(), Some("NOT_FOUND"));
111    }
112
113    #[test]
114    fn exhausted_delegates_status() {
115        let inner = Error::api(503, "SERVICE_UNAVAILABLE", "unavailable");
116        let err = Error::Exhausted {
117            attempts: 3,
118            source: Box::new(inner),
119        };
120        assert!(err.is_exhausted());
121        assert!(!err.is_api_error());
122        assert_eq!(err.status(), Some(503));
123        assert_eq!(err.code(), None);
124    }
125
126    #[test]
127    fn exhausted_with_non_api_source() {
128        let inner = Error::Deserialize("bad json".to_string());
129        let err = Error::Exhausted {
130            attempts: 2,
131            source: Box::new(inner),
132        };
133        assert!(err.is_exhausted());
134        assert_eq!(err.status(), None);
135    }
136
137    #[test]
138    fn deserialize_error() {
139        let err = Error::Deserialize("bad json".to_string());
140        assert!(!err.is_api_error());
141        assert!(!err.is_exhausted());
142        assert_eq!(err.status(), None);
143        assert_eq!(err.code(), None);
144    }
145
146    #[test]
147    fn sse_error() {
148        let err = Error::Sse("connection reset".to_string());
149        assert!(!err.is_api_error());
150        assert!(!err.is_exhausted());
151        assert_eq!(err.status(), None);
152    }
153
154    #[test]
155    fn exhausted_display() {
156        let inner = Error::api(503, "UNAVAILABLE", "down");
157        let err = Error::Exhausted {
158            attempts: 4,
159            source: Box::new(inner),
160        };
161        let msg = format!("{err}");
162        assert!(msg.contains("4 attempts"));
163    }
164}