Skip to main content

ironflow_api/
error.rs

1//! REST API error types and responses.
2//!
3//! [`ApiError`] is the primary error type for all API handlers. It implements
4//! [`IntoResponse`] to serialize errors to JSON
5//! with proper HTTP status codes.
6
7use axum::Json;
8use axum::http::StatusCode;
9use axum::response::{IntoResponse, Response};
10use ironflow_engine::error::MONTHLY_BUDGET_EXCEEDED_CODE;
11use ironflow_store::error::StoreError;
12use ironflow_types::ErrorEnvelope;
13use serde_json::{Value, json};
14use thiserror::Error;
15use tracing::{error, warn};
16use uuid::Uuid;
17
18/// Error type for REST API operations.
19///
20/// Maps to appropriate HTTP status codes and error codes in the JSON response.
21///
22/// # Examples
23///
24/// ```
25/// use ironflow_api::error::ApiError;
26/// use uuid::Uuid;
27///
28/// let err = ApiError::RunNotFound(Uuid::nil());
29/// assert_eq!(err.to_string(), "run not found");
30/// ```
31#[derive(Debug, Error)]
32pub enum ApiError {
33    /// The requested run does not exist (404).
34    #[error("run not found")]
35    RunNotFound(Uuid),
36
37    /// The requested step does not exist (404).
38    #[error("step not found")]
39    StepNotFound(Uuid),
40
41    /// Workflow not found (404).
42    #[error("workflow not found")]
43    WorkflowNotFound(String),
44
45    /// Bad request: invalid input (400).
46    #[error("{0}")]
47    BadRequest(String),
48
49    /// The request conflicts with the current state of the resource (409).
50    #[error("{0}")]
51    Conflict(String),
52
53    /// Authentication required (401).
54    #[error("authentication required")]
55    Unauthorized,
56
57    /// Invalid credentials (401).
58    #[error("invalid credentials")]
59    InvalidCredentials,
60
61    /// Email already taken (409).
62    #[error("email already exists")]
63    DuplicateEmail,
64
65    /// Username already taken (409).
66    #[error("username already exists")]
67    DuplicateUsername,
68
69    /// API key not found (404).
70    #[error("API key not found")]
71    ApiKeyNotFound(Uuid),
72
73    /// User not found (404).
74    #[error("user not found")]
75    UserNotFound(Uuid),
76
77    /// Insufficient permissions for this action (403).
78    #[error("insufficient permissions")]
79    Forbidden,
80
81    /// Secret not found (404).
82    #[error("secret not found")]
83    SecretNotFound(String),
84
85    /// Insufficient scope (403).
86    #[error("insufficient scope")]
87    InsufficientScope,
88
89    /// The idempotency key is already bound to a different request (409).
90    ///
91    /// Carries the run holding the key so the client can inspect it.
92    #[error("idempotency key already used with a different request")]
93    IdempotencyKeyConflict(Uuid),
94    /// The global monthly cost quota is exhausted (429).
95    ///
96    /// Only blocks the creation of new runs; runs already in flight continue.
97    #[error("{0}")]
98    MonthlyBudgetExceeded(String),
99
100    /// The requested artifact does not exist (404).
101    #[error("artifact not found")]
102    ArtifactNotFound(String),
103
104    /// No artifact storage backend is configured (501).
105    ///
106    /// Every other endpoint keeps working: an upgraded deployment that has not
107    /// opted into artifacts is degraded here only.
108    #[error("artifact storage is not configured")]
109    ArtifactStorageUnavailable,
110
111    /// The uploaded payload exceeded the artifact size limit (413).
112    #[error("artifact exceeds the size limit")]
113    ArtifactTooLarge,
114
115    /// Schedule not found (404).
116    #[error("schedule not found")]
117    ScheduleNotFound(Uuid),
118
119    /// Store operation failed (500).
120    #[error("database error")]
121    Store(StoreError),
122
123    /// Internal server error (500).
124    #[error("internal server error")]
125    Internal(String),
126
127    /// An upstream service is unreachable or returned an error (502).
128    #[error("upstream service unavailable")]
129    BadGateway(String),
130}
131
132impl From<StoreError> for ApiError {
133    fn from(e: StoreError) -> Self {
134        match e {
135            StoreError::ScheduleNotFound(id) => ApiError::ScheduleNotFound(id),
136            other => ApiError::Store(other),
137        }
138    }
139}
140
141impl ApiError {
142    /// Return the error code for JSON serialization.
143    fn code(&self) -> &str {
144        match self {
145            ApiError::RunNotFound(_) => "RUN_NOT_FOUND",
146            ApiError::StepNotFound(_) => "STEP_NOT_FOUND",
147            ApiError::WorkflowNotFound(_) => "WORKFLOW_NOT_FOUND",
148            ApiError::BadRequest(_) => "BAD_REQUEST",
149            ApiError::Conflict(_) => "CONFLICT",
150            ApiError::Unauthorized => "UNAUTHORIZED",
151            ApiError::InvalidCredentials => "INVALID_CREDENTIALS",
152            ApiError::DuplicateEmail => "DUPLICATE_EMAIL",
153            ApiError::DuplicateUsername => "DUPLICATE_USERNAME",
154            ApiError::ApiKeyNotFound(_) => "API_KEY_NOT_FOUND",
155            ApiError::UserNotFound(_) => "USER_NOT_FOUND",
156            ApiError::SecretNotFound(_) => "SECRET_NOT_FOUND",
157            ApiError::Forbidden => "FORBIDDEN",
158            ApiError::InsufficientScope => "INSUFFICIENT_SCOPE",
159            ApiError::IdempotencyKeyConflict(_) => "IDEMPOTENCY_KEY_CONFLICT",
160            ApiError::MonthlyBudgetExceeded(_) => MONTHLY_BUDGET_EXCEEDED_CODE,
161            ApiError::ArtifactNotFound(_) => "ARTIFACT_NOT_FOUND",
162            ApiError::ArtifactStorageUnavailable => "ARTIFACT_STORAGE_UNAVAILABLE",
163            ApiError::ArtifactTooLarge => "ARTIFACT_TOO_LARGE",
164            ApiError::ScheduleNotFound(_) => "SCHEDULE_NOT_FOUND",
165            ApiError::Store(StoreError::Crypto(_)) => "SECRET_STORE_UNAVAILABLE",
166            ApiError::Store(StoreError::DuplicateArtifact { .. }) => "DUPLICATE_ARTIFACT",
167            ApiError::Store(StoreError::LeaseLost { .. }) => "LEASE_LOST",
168            ApiError::Store(_) => "DATABASE_ERROR",
169            ApiError::Internal(_) => "INTERNAL_ERROR",
170            ApiError::BadGateway(_) => "BAD_GATEWAY",
171        }
172    }
173
174    /// Return the HTTP status code for this error.
175    fn status(&self) -> StatusCode {
176        match self {
177            ApiError::RunNotFound(_) => StatusCode::NOT_FOUND,
178            ApiError::StepNotFound(_) => StatusCode::NOT_FOUND,
179            ApiError::WorkflowNotFound(_) => StatusCode::NOT_FOUND,
180            ApiError::SecretNotFound(_) => StatusCode::NOT_FOUND,
181            ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
182            ApiError::Conflict(_) => StatusCode::CONFLICT,
183            ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
184            ApiError::InvalidCredentials => StatusCode::UNAUTHORIZED,
185            ApiError::DuplicateEmail => StatusCode::CONFLICT,
186            ApiError::DuplicateUsername => StatusCode::CONFLICT,
187            ApiError::ApiKeyNotFound(_) => StatusCode::NOT_FOUND,
188            ApiError::UserNotFound(_) => StatusCode::NOT_FOUND,
189            ApiError::Forbidden => StatusCode::FORBIDDEN,
190            ApiError::InsufficientScope => StatusCode::FORBIDDEN,
191            ApiError::IdempotencyKeyConflict(_) => StatusCode::CONFLICT,
192            ApiError::MonthlyBudgetExceeded(_) => StatusCode::TOO_MANY_REQUESTS,
193            ApiError::ArtifactNotFound(_) => StatusCode::NOT_FOUND,
194            ApiError::ArtifactStorageUnavailable => StatusCode::NOT_IMPLEMENTED,
195            ApiError::ArtifactTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
196            ApiError::ScheduleNotFound(_) => StatusCode::NOT_FOUND,
197            ApiError::Store(StoreError::Crypto(_)) => StatusCode::NOT_IMPLEMENTED,
198            ApiError::Store(StoreError::DuplicateArtifact { .. }) => StatusCode::CONFLICT,
199            ApiError::Store(StoreError::LeaseLost { .. }) => StatusCode::CONFLICT,
200            ApiError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
201            ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
202            ApiError::BadGateway(_) => StatusCode::BAD_GATEWAY,
203        }
204    }
205
206    /// Structured context attached to the JSON error body, if any.
207    ///
208    /// Never carries internal detail: only identifiers the caller is already
209    /// entitled to see.
210    fn details(&self) -> Option<Value> {
211        match self {
212            ApiError::IdempotencyKeyConflict(run_id) => Some(json!({ "run_id": run_id })),
213            _ => None,
214        }
215    }
216}
217
218impl IntoResponse for ApiError {
219    fn into_response(self) -> Response {
220        let status = self.status();
221        let code = self.code().to_string();
222        let details = self.details();
223
224        let message = match &self {
225            ApiError::Store(StoreError::Crypto(_)) => {
226                "secret store not configured (set IRONFLOW_SECRET_KEYS)".to_string()
227            }
228            // Carries the caller-facing detail in its own Display impl,
229            // unlike the generic "database error" of ApiError::Store.
230            ApiError::Store(e @ StoreError::LeaseLost { .. }) => e.to_string(),
231            _ => self.to_string(),
232        };
233
234        match &self {
235            // A lost lease is a client-side condition, not a server fault:
236            // it must not page anyone.
237            ApiError::Store(e @ StoreError::LeaseLost { .. }) => {
238                warn!(error = %e, code = %code, "lease refused")
239            }
240            ApiError::Store(e) => error!(error = %e, code = %code, "store error"),
241            ApiError::Internal(detail) => {
242                error!(detail = %detail, code = %code, "internal error")
243            }
244            _ => {}
245        }
246
247        let envelope = ErrorEnvelope {
248            code,
249            message,
250            details,
251        };
252
253        (status, Json(json!({ "error": envelope }))).into_response()
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn run_not_found_code() {
263        let err = ApiError::RunNotFound(Uuid::nil());
264        assert_eq!(err.code(), "RUN_NOT_FOUND");
265    }
266
267    #[test]
268    fn run_not_found_status() {
269        let err = ApiError::RunNotFound(Uuid::nil());
270        assert_eq!(err.status(), StatusCode::NOT_FOUND);
271    }
272
273    #[test]
274    fn bad_request_status() {
275        let err = ApiError::BadRequest("invalid field".to_string());
276        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
277        assert_eq!(err.code(), "BAD_REQUEST");
278    }
279
280    #[test]
281    fn conflict_status() {
282        let err = ApiError::Conflict("run is already waiting for a retry".to_string());
283        assert_eq!(err.status(), StatusCode::CONFLICT);
284        assert_eq!(err.code(), "CONFLICT");
285    }
286
287    #[test]
288    fn internal_error_status() {
289        let err = ApiError::Internal("something went wrong".to_string());
290        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
291        assert_eq!(err.code(), "INTERNAL_ERROR");
292    }
293
294    #[test]
295    fn error_to_response() {
296        let err = ApiError::BadRequest("invalid input".to_string());
297        let response = err.into_response();
298        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
299    }
300
301    #[test]
302    fn unauthorized_status() {
303        let err = ApiError::Unauthorized;
304        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
305        assert_eq!(err.code(), "UNAUTHORIZED");
306    }
307
308    #[test]
309    fn invalid_credentials_status() {
310        let err = ApiError::InvalidCredentials;
311        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
312        assert_eq!(err.code(), "INVALID_CREDENTIALS");
313    }
314
315    #[test]
316    fn duplicate_email_status() {
317        let err = ApiError::DuplicateEmail;
318        assert_eq!(err.status(), StatusCode::CONFLICT);
319        assert_eq!(err.code(), "DUPLICATE_EMAIL");
320    }
321
322    #[test]
323    fn duplicate_username_status() {
324        let err = ApiError::DuplicateUsername;
325        assert_eq!(err.status(), StatusCode::CONFLICT);
326        assert_eq!(err.code(), "DUPLICATE_USERNAME");
327    }
328
329    #[test]
330    fn workflow_not_found_status() {
331        let err = ApiError::WorkflowNotFound("test".to_string());
332        assert_eq!(err.status(), StatusCode::NOT_FOUND);
333        assert_eq!(err.code(), "WORKFLOW_NOT_FOUND");
334    }
335
336    #[test]
337    fn step_not_found_status() {
338        let err = ApiError::StepNotFound(Uuid::nil());
339        assert_eq!(err.status(), StatusCode::NOT_FOUND);
340        assert_eq!(err.code(), "STEP_NOT_FOUND");
341    }
342
343    #[test]
344    fn user_not_found_status() {
345        let err = ApiError::UserNotFound(Uuid::nil());
346        assert_eq!(err.status(), StatusCode::NOT_FOUND);
347        assert_eq!(err.code(), "USER_NOT_FOUND");
348    }
349
350    #[test]
351    fn forbidden_status() {
352        let err = ApiError::Forbidden;
353        assert_eq!(err.status(), StatusCode::FORBIDDEN);
354        assert_eq!(err.code(), "FORBIDDEN");
355    }
356
357    #[test]
358    fn secret_not_found_status() {
359        let err = ApiError::SecretNotFound("demo/api-key".to_string());
360        assert_eq!(err.status(), StatusCode::NOT_FOUND);
361        assert_eq!(err.code(), "SECRET_NOT_FOUND");
362    }
363
364    #[test]
365    fn monthly_budget_exceeded_status_and_code() {
366        let err = ApiError::MonthlyBudgetExceeded("quota exhausted".to_string());
367        assert_eq!(err.status(), StatusCode::TOO_MANY_REQUESTS);
368        assert_eq!(err.code(), "MONTHLY_BUDGET_EXCEEDED");
369        assert_eq!(err.to_string(), "quota exhausted");
370    }
371}