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    /// Approval delegation not found (404).
120    #[error("approval delegation not found")]
121    DelegationNotFound(Uuid),
122
123    /// Provider Account not found (404), by id or name.
124    #[error("provider account not found: {0}")]
125    ProviderAccountNotFound(String),
126
127    /// The account credential was rejected, by format or by the provider (422).
128    #[error("{0}")]
129    AccountCredentialRejected(String),
130
131    /// Store operation failed (500).
132    #[error("database error")]
133    Store(StoreError),
134
135    /// Internal server error (500).
136    #[error("internal server error")]
137    Internal(String),
138
139    /// An upstream service is unreachable or returned an error (502).
140    #[error("upstream service unavailable")]
141    BadGateway(String),
142
143    /// No template registry exists at the configured URL (404).
144    #[error("template registry not found, check IRONFLOW_REGISTRY_URL")]
145    RegistryNotFound,
146
147    /// The template registry host cannot be reached (502).
148    #[error("template registry unreachable")]
149    RegistryUnreachable,
150
151    /// A submitted value does not match the expected JSON schema (422).
152    ///
153    /// Carries one human-readable message per violation, returned to the
154    /// caller under `details.errors`.
155    #[error("input does not match the expected schema")]
156    InvalidInput(Vec<String>),
157}
158
159impl From<StoreError> for ApiError {
160    fn from(e: StoreError) -> Self {
161        match e {
162            StoreError::ScheduleNotFound(id) => ApiError::ScheduleNotFound(id),
163            StoreError::DelegationNotFound(id) => ApiError::DelegationNotFound(id),
164            StoreError::ProviderAccountNotFound(id) => {
165                ApiError::ProviderAccountNotFound(id.to_string())
166            }
167            StoreError::DuplicateProviderAccount(name) => {
168                ApiError::Conflict(format!("provider account '{name}' already exists"))
169            }
170            other => ApiError::Store(other),
171        }
172    }
173}
174
175impl ApiError {
176    /// Return the error code for JSON serialization.
177    fn code(&self) -> &str {
178        match self {
179            ApiError::RunNotFound(_) => "RUN_NOT_FOUND",
180            ApiError::StepNotFound(_) => "STEP_NOT_FOUND",
181            ApiError::WorkflowNotFound(_) => "WORKFLOW_NOT_FOUND",
182            ApiError::BadRequest(_) => "BAD_REQUEST",
183            ApiError::Conflict(_) => "CONFLICT",
184            ApiError::Unauthorized => "UNAUTHORIZED",
185            ApiError::InvalidCredentials => "INVALID_CREDENTIALS",
186            ApiError::DuplicateEmail => "DUPLICATE_EMAIL",
187            ApiError::DuplicateUsername => "DUPLICATE_USERNAME",
188            ApiError::ApiKeyNotFound(_) => "API_KEY_NOT_FOUND",
189            ApiError::UserNotFound(_) => "USER_NOT_FOUND",
190            ApiError::SecretNotFound(_) => "SECRET_NOT_FOUND",
191            ApiError::Forbidden => "FORBIDDEN",
192            ApiError::InsufficientScope => "INSUFFICIENT_SCOPE",
193            ApiError::IdempotencyKeyConflict(_) => "IDEMPOTENCY_KEY_CONFLICT",
194            ApiError::MonthlyBudgetExceeded(_) => MONTHLY_BUDGET_EXCEEDED_CODE,
195            ApiError::ArtifactNotFound(_) => "ARTIFACT_NOT_FOUND",
196            ApiError::ArtifactStorageUnavailable => "ARTIFACT_STORAGE_UNAVAILABLE",
197            ApiError::ArtifactTooLarge => "ARTIFACT_TOO_LARGE",
198            ApiError::ScheduleNotFound(_) => "SCHEDULE_NOT_FOUND",
199            ApiError::DelegationNotFound(_) => "DELEGATION_NOT_FOUND",
200            ApiError::ProviderAccountNotFound(_) => "PROVIDER_ACCOUNT_NOT_FOUND",
201            ApiError::AccountCredentialRejected(_) => "ACCOUNT_CREDENTIAL_REJECTED",
202            ApiError::Store(StoreError::Crypto(_)) => "SECRET_STORE_UNAVAILABLE",
203            ApiError::Store(StoreError::DuplicateArtifact { .. }) => "DUPLICATE_ARTIFACT",
204            ApiError::Store(StoreError::LeaseLost { .. }) => "LEASE_LOST",
205            ApiError::Store(_) => "DATABASE_ERROR",
206            ApiError::Internal(_) => "INTERNAL_ERROR",
207            ApiError::BadGateway(_) => "BAD_GATEWAY",
208            ApiError::RegistryNotFound => "REGISTRY_NOT_FOUND",
209            ApiError::RegistryUnreachable => "REGISTRY_UNREACHABLE",
210            ApiError::InvalidInput(_) => "INVALID_INPUT",
211        }
212    }
213
214    /// Return the HTTP status code for this error.
215    fn status(&self) -> StatusCode {
216        match self {
217            ApiError::RunNotFound(_) => StatusCode::NOT_FOUND,
218            ApiError::StepNotFound(_) => StatusCode::NOT_FOUND,
219            ApiError::WorkflowNotFound(_) => StatusCode::NOT_FOUND,
220            ApiError::SecretNotFound(_) => StatusCode::NOT_FOUND,
221            ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
222            ApiError::Conflict(_) => StatusCode::CONFLICT,
223            ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
224            ApiError::InvalidCredentials => StatusCode::UNAUTHORIZED,
225            ApiError::DuplicateEmail => StatusCode::CONFLICT,
226            ApiError::DuplicateUsername => StatusCode::CONFLICT,
227            ApiError::ApiKeyNotFound(_) => StatusCode::NOT_FOUND,
228            ApiError::UserNotFound(_) => StatusCode::NOT_FOUND,
229            ApiError::Forbidden => StatusCode::FORBIDDEN,
230            ApiError::InsufficientScope => StatusCode::FORBIDDEN,
231            ApiError::IdempotencyKeyConflict(_) => StatusCode::CONFLICT,
232            ApiError::MonthlyBudgetExceeded(_) => StatusCode::TOO_MANY_REQUESTS,
233            ApiError::ArtifactNotFound(_) => StatusCode::NOT_FOUND,
234            ApiError::ArtifactStorageUnavailable => StatusCode::NOT_IMPLEMENTED,
235            ApiError::ArtifactTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
236            ApiError::ScheduleNotFound(_) => StatusCode::NOT_FOUND,
237            ApiError::DelegationNotFound(_) => StatusCode::NOT_FOUND,
238            ApiError::ProviderAccountNotFound(_) => StatusCode::NOT_FOUND,
239            ApiError::AccountCredentialRejected(_) => StatusCode::UNPROCESSABLE_ENTITY,
240            ApiError::Store(StoreError::Crypto(_)) => StatusCode::NOT_IMPLEMENTED,
241            ApiError::Store(StoreError::DuplicateArtifact { .. }) => StatusCode::CONFLICT,
242            ApiError::Store(StoreError::LeaseLost { .. }) => StatusCode::CONFLICT,
243            ApiError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
244            ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
245            ApiError::BadGateway(_) => StatusCode::BAD_GATEWAY,
246            ApiError::RegistryNotFound => StatusCode::NOT_FOUND,
247            ApiError::RegistryUnreachable => StatusCode::BAD_GATEWAY,
248            ApiError::InvalidInput(_) => StatusCode::UNPROCESSABLE_ENTITY,
249        }
250    }
251
252    /// Structured context attached to the JSON error body, if any.
253    ///
254    /// Never carries internal detail: only identifiers the caller is already
255    /// entitled to see.
256    fn details(&self) -> Option<Value> {
257        match self {
258            ApiError::IdempotencyKeyConflict(run_id) => Some(json!({ "run_id": run_id })),
259            ApiError::InvalidInput(errors) => Some(json!({ "errors": errors })),
260            _ => None,
261        }
262    }
263}
264
265impl IntoResponse for ApiError {
266    fn into_response(self) -> Response {
267        let status = self.status();
268        let code = self.code().to_string();
269        let details = self.details();
270
271        let message = match &self {
272            ApiError::Store(StoreError::Crypto(_)) => {
273                "secret store not configured (set IRONFLOW_SECRET_KEYS)".to_string()
274            }
275            // Carries the caller-facing detail in its own Display impl,
276            // unlike the generic "database error" of ApiError::Store.
277            ApiError::Store(e @ StoreError::LeaseLost { .. }) => e.to_string(),
278            _ => self.to_string(),
279        };
280
281        match &self {
282            // A lost lease is a client-side condition, not a server fault:
283            // it must not page anyone.
284            ApiError::Store(e @ StoreError::LeaseLost { .. }) => {
285                warn!(error = %e, code = %code, "lease refused")
286            }
287            ApiError::Store(e) => error!(error = %e, code = %code, "store error"),
288            ApiError::Internal(detail) => {
289                error!(detail = %detail, code = %code, "internal error")
290            }
291            _ => {}
292        }
293
294        let envelope = ErrorEnvelope {
295            code,
296            message,
297            details,
298        };
299
300        (status, Json(json!({ "error": envelope }))).into_response()
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use super::*;
307
308    #[test]
309    fn run_not_found_code() {
310        let err = ApiError::RunNotFound(Uuid::nil());
311        assert_eq!(err.code(), "RUN_NOT_FOUND");
312    }
313
314    #[test]
315    fn run_not_found_status() {
316        let err = ApiError::RunNotFound(Uuid::nil());
317        assert_eq!(err.status(), StatusCode::NOT_FOUND);
318    }
319
320    #[test]
321    fn bad_request_status() {
322        let err = ApiError::BadRequest("invalid field".to_string());
323        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
324        assert_eq!(err.code(), "BAD_REQUEST");
325    }
326
327    #[test]
328    fn conflict_status() {
329        let err = ApiError::Conflict("run is already waiting for a retry".to_string());
330        assert_eq!(err.status(), StatusCode::CONFLICT);
331        assert_eq!(err.code(), "CONFLICT");
332    }
333
334    #[test]
335    fn invalid_input_status_and_details() {
336        let err = ApiError::InvalidInput(vec!["\"answers\" is a required property".to_string()]);
337        assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
338        assert_eq!(err.code(), "INVALID_INPUT");
339        assert_eq!(
340            err.details(),
341            Some(json!({ "errors": ["\"answers\" is a required property"] }))
342        );
343    }
344
345    #[test]
346    fn internal_error_status() {
347        let err = ApiError::Internal("something went wrong".to_string());
348        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
349        assert_eq!(err.code(), "INTERNAL_ERROR");
350    }
351
352    #[test]
353    fn error_to_response() {
354        let err = ApiError::BadRequest("invalid input".to_string());
355        let response = err.into_response();
356        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
357    }
358
359    #[test]
360    fn unauthorized_status() {
361        let err = ApiError::Unauthorized;
362        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
363        assert_eq!(err.code(), "UNAUTHORIZED");
364    }
365
366    #[test]
367    fn invalid_credentials_status() {
368        let err = ApiError::InvalidCredentials;
369        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
370        assert_eq!(err.code(), "INVALID_CREDENTIALS");
371    }
372
373    #[test]
374    fn duplicate_email_status() {
375        let err = ApiError::DuplicateEmail;
376        assert_eq!(err.status(), StatusCode::CONFLICT);
377        assert_eq!(err.code(), "DUPLICATE_EMAIL");
378    }
379
380    #[test]
381    fn duplicate_username_status() {
382        let err = ApiError::DuplicateUsername;
383        assert_eq!(err.status(), StatusCode::CONFLICT);
384        assert_eq!(err.code(), "DUPLICATE_USERNAME");
385    }
386
387    #[test]
388    fn workflow_not_found_status() {
389        let err = ApiError::WorkflowNotFound("test".to_string());
390        assert_eq!(err.status(), StatusCode::NOT_FOUND);
391        assert_eq!(err.code(), "WORKFLOW_NOT_FOUND");
392    }
393
394    #[test]
395    fn step_not_found_status() {
396        let err = ApiError::StepNotFound(Uuid::nil());
397        assert_eq!(err.status(), StatusCode::NOT_FOUND);
398        assert_eq!(err.code(), "STEP_NOT_FOUND");
399    }
400
401    #[test]
402    fn user_not_found_status() {
403        let err = ApiError::UserNotFound(Uuid::nil());
404        assert_eq!(err.status(), StatusCode::NOT_FOUND);
405        assert_eq!(err.code(), "USER_NOT_FOUND");
406    }
407
408    #[test]
409    fn forbidden_status() {
410        let err = ApiError::Forbidden;
411        assert_eq!(err.status(), StatusCode::FORBIDDEN);
412        assert_eq!(err.code(), "FORBIDDEN");
413    }
414
415    #[test]
416    fn secret_not_found_status() {
417        let err = ApiError::SecretNotFound("demo/api-key".to_string());
418        assert_eq!(err.status(), StatusCode::NOT_FOUND);
419        assert_eq!(err.code(), "SECRET_NOT_FOUND");
420    }
421
422    #[test]
423    fn delegation_not_found_status_and_code() {
424        let err = ApiError::DelegationNotFound(Uuid::nil());
425        assert_eq!(err.status(), StatusCode::NOT_FOUND);
426        assert_eq!(err.code(), "DELEGATION_NOT_FOUND");
427    }
428
429    #[test]
430    fn provider_account_errors_status_and_code() {
431        let err = ApiError::ProviderAccountNotFound("perso".to_string());
432        assert_eq!(err.status(), StatusCode::NOT_FOUND);
433        assert_eq!(err.code(), "PROVIDER_ACCOUNT_NOT_FOUND");
434
435        let err = ApiError::AccountCredentialRejected("token rejected".to_string());
436        assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
437        assert_eq!(err.code(), "ACCOUNT_CREDENTIAL_REJECTED");
438        assert_eq!(err.to_string(), "token rejected");
439    }
440
441    #[test]
442    fn provider_account_store_errors_map_to_http() {
443        let id = Uuid::now_v7();
444        let err = ApiError::from(StoreError::ProviderAccountNotFound(id));
445        assert_eq!(err.status(), StatusCode::NOT_FOUND);
446        assert!(err.to_string().contains(&id.to_string()));
447
448        let err = ApiError::from(StoreError::DuplicateProviderAccount("perso".to_string()));
449        assert_eq!(err.status(), StatusCode::CONFLICT);
450        assert!(err.to_string().contains("perso"));
451    }
452
453    #[test]
454    fn monthly_budget_exceeded_status_and_code() {
455        let err = ApiError::MonthlyBudgetExceeded("quota exhausted".to_string());
456        assert_eq!(err.status(), StatusCode::TOO_MANY_REQUESTS);
457        assert_eq!(err.code(), "MONTHLY_BUDGET_EXCEEDED");
458        assert_eq!(err.to_string(), "quota exhausted");
459    }
460}