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_auth::password::PasswordPolicyError;
11use ironflow_engine::error::MONTHLY_BUDGET_EXCEEDED_CODE;
12use ironflow_store::error::StoreError;
13use ironflow_types::ErrorEnvelope;
14use serde_json::{Value, json};
15use thiserror::Error;
16use tracing::{error, warn};
17use uuid::Uuid;
18
19/// Error type for REST API operations.
20///
21/// Maps to appropriate HTTP status codes and error codes in the JSON response.
22///
23/// # Examples
24///
25/// ```
26/// use ironflow_api::error::ApiError;
27/// use uuid::Uuid;
28///
29/// let err = ApiError::RunNotFound(Uuid::nil());
30/// assert_eq!(err.to_string(), "run not found");
31/// ```
32#[derive(Debug, Error)]
33pub enum ApiError {
34    /// The requested run does not exist (404).
35    #[error("run not found")]
36    RunNotFound(Uuid),
37
38    /// The requested step does not exist (404).
39    #[error("step not found")]
40    StepNotFound(Uuid),
41
42    /// Workflow not found (404).
43    #[error("workflow not found")]
44    WorkflowNotFound(String),
45
46    /// Bad request: invalid input (400).
47    #[error("{0}")]
48    BadRequest(String),
49
50    /// The request conflicts with the current state of the resource (409).
51    #[error("{0}")]
52    Conflict(String),
53
54    /// Authentication required (401).
55    #[error("authentication required")]
56    Unauthorized,
57
58    /// Invalid credentials (401).
59    #[error("invalid credentials")]
60    InvalidCredentials,
61
62    /// Email already taken (409).
63    #[error("email already exists")]
64    DuplicateEmail,
65
66    /// Username already taken (409).
67    #[error("username already exists")]
68    DuplicateUsername,
69
70    /// The chosen password breaks the strength policy (400).
71    ///
72    /// The message says which rule, so the user knows what to change.
73    #[error("{0}")]
74    WeakPassword(#[from] PasswordPolicyError),
75
76    /// API key not found (404).
77    #[error("API key not found")]
78    ApiKeyNotFound(Uuid),
79
80    /// User not found (404).
81    #[error("user not found")]
82    UserNotFound(Uuid),
83
84    /// Insufficient permissions for this action (403).
85    #[error("insufficient permissions")]
86    Forbidden,
87
88    /// Secret not found (404).
89    #[error("secret not found")]
90    SecretNotFound(String),
91
92    /// Insufficient scope (403).
93    #[error("insufficient scope")]
94    InsufficientScope,
95
96    /// The idempotency key is already bound to a different request (409).
97    ///
98    /// Carries the run holding the key so the client can inspect it.
99    #[error("idempotency key already used with a different request")]
100    IdempotencyKeyConflict(Uuid),
101    /// The global monthly cost quota is exhausted (429).
102    ///
103    /// Only blocks the creation of new runs; runs already in flight continue.
104    #[error("{0}")]
105    MonthlyBudgetExceeded(String),
106
107    /// The requested artifact does not exist (404).
108    #[error("artifact not found")]
109    ArtifactNotFound(String),
110
111    /// No artifact storage backend is configured (501).
112    ///
113    /// Every other endpoint keeps working: an upgraded deployment that has not
114    /// opted into artifacts is degraded here only.
115    #[error("artifact storage is not configured")]
116    ArtifactStorageUnavailable,
117
118    /// The uploaded payload exceeded the artifact size limit (413).
119    #[error("artifact exceeds the size limit")]
120    ArtifactTooLarge,
121
122    /// Schedule not found (404).
123    #[error("schedule not found")]
124    ScheduleNotFound(Uuid),
125
126    /// Approval delegation not found (404).
127    #[error("approval delegation not found")]
128    DelegationNotFound(Uuid),
129
130    /// Provider Account not found (404), by id or name.
131    #[error("provider account not found: {0}")]
132    ProviderAccountNotFound(String),
133
134    /// The account credential was rejected, by format or by the provider (422).
135    #[error("{0}")]
136    AccountCredentialRejected(String),
137
138    /// Store operation failed (500).
139    #[error("database error")]
140    Store(StoreError),
141
142    /// Internal server error (500).
143    #[error("internal server error")]
144    Internal(String),
145
146    /// An upstream service is unreachable or returned an error (502).
147    #[error("upstream service unavailable")]
148    BadGateway(String),
149
150    /// No template registry exists at the configured URL (404).
151    #[error("template registry not found, check IRONFLOW_REGISTRY_URL")]
152    RegistryNotFound,
153
154    /// The template registry host cannot be reached (502).
155    #[error("template registry unreachable")]
156    RegistryUnreachable,
157
158    /// A submitted value does not match the expected JSON schema (422).
159    ///
160    /// Carries one human-readable message per violation, returned to the
161    /// caller under `details.errors`.
162    #[error("input does not match the expected schema")]
163    InvalidInput(Vec<String>),
164}
165
166impl From<StoreError> for ApiError {
167    fn from(e: StoreError) -> Self {
168        match e {
169            StoreError::ScheduleNotFound(id) => ApiError::ScheduleNotFound(id),
170            StoreError::DelegationNotFound(id) => ApiError::DelegationNotFound(id),
171            StoreError::ProviderAccountNotFound(id) => {
172                ApiError::ProviderAccountNotFound(id.to_string())
173            }
174            StoreError::DuplicateProviderAccount(name) => {
175                ApiError::Conflict(format!("provider account '{name}' already exists"))
176            }
177            other => ApiError::Store(other),
178        }
179    }
180}
181
182impl ApiError {
183    /// Return the error code for JSON serialization.
184    fn code(&self) -> &str {
185        match self {
186            ApiError::RunNotFound(_) => "RUN_NOT_FOUND",
187            ApiError::StepNotFound(_) => "STEP_NOT_FOUND",
188            ApiError::WorkflowNotFound(_) => "WORKFLOW_NOT_FOUND",
189            ApiError::BadRequest(_) => "BAD_REQUEST",
190            ApiError::Conflict(_) => "CONFLICT",
191            ApiError::Unauthorized => "UNAUTHORIZED",
192            ApiError::InvalidCredentials => "INVALID_CREDENTIALS",
193            ApiError::DuplicateEmail => "DUPLICATE_EMAIL",
194            ApiError::DuplicateUsername => "DUPLICATE_USERNAME",
195            ApiError::WeakPassword(_) => "WEAK_PASSWORD",
196            ApiError::ApiKeyNotFound(_) => "API_KEY_NOT_FOUND",
197            ApiError::UserNotFound(_) => "USER_NOT_FOUND",
198            ApiError::SecretNotFound(_) => "SECRET_NOT_FOUND",
199            ApiError::Forbidden => "FORBIDDEN",
200            ApiError::InsufficientScope => "INSUFFICIENT_SCOPE",
201            ApiError::IdempotencyKeyConflict(_) => "IDEMPOTENCY_KEY_CONFLICT",
202            ApiError::MonthlyBudgetExceeded(_) => MONTHLY_BUDGET_EXCEEDED_CODE,
203            ApiError::ArtifactNotFound(_) => "ARTIFACT_NOT_FOUND",
204            ApiError::ArtifactStorageUnavailable => "ARTIFACT_STORAGE_UNAVAILABLE",
205            ApiError::ArtifactTooLarge => "ARTIFACT_TOO_LARGE",
206            ApiError::ScheduleNotFound(_) => "SCHEDULE_NOT_FOUND",
207            ApiError::DelegationNotFound(_) => "DELEGATION_NOT_FOUND",
208            ApiError::ProviderAccountNotFound(_) => "PROVIDER_ACCOUNT_NOT_FOUND",
209            ApiError::AccountCredentialRejected(_) => "ACCOUNT_CREDENTIAL_REJECTED",
210            ApiError::Store(StoreError::Crypto(_)) => "SECRET_STORE_UNAVAILABLE",
211            ApiError::Store(StoreError::DuplicateArtifact { .. }) => "DUPLICATE_ARTIFACT",
212            ApiError::Store(StoreError::LeaseLost { .. }) => "LEASE_LOST",
213            ApiError::Store(_) => "DATABASE_ERROR",
214            ApiError::Internal(_) => "INTERNAL_ERROR",
215            ApiError::BadGateway(_) => "BAD_GATEWAY",
216            ApiError::RegistryNotFound => "REGISTRY_NOT_FOUND",
217            ApiError::RegistryUnreachable => "REGISTRY_UNREACHABLE",
218            ApiError::InvalidInput(_) => "INVALID_INPUT",
219        }
220    }
221
222    /// Return the HTTP status code for this error.
223    fn status(&self) -> StatusCode {
224        match self {
225            ApiError::RunNotFound(_) => StatusCode::NOT_FOUND,
226            ApiError::StepNotFound(_) => StatusCode::NOT_FOUND,
227            ApiError::WorkflowNotFound(_) => StatusCode::NOT_FOUND,
228            ApiError::SecretNotFound(_) => StatusCode::NOT_FOUND,
229            ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
230            ApiError::Conflict(_) => StatusCode::CONFLICT,
231            ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
232            ApiError::InvalidCredentials => StatusCode::UNAUTHORIZED,
233            ApiError::DuplicateEmail => StatusCode::CONFLICT,
234            ApiError::DuplicateUsername => StatusCode::CONFLICT,
235            ApiError::WeakPassword(_) => StatusCode::BAD_REQUEST,
236            ApiError::ApiKeyNotFound(_) => StatusCode::NOT_FOUND,
237            ApiError::UserNotFound(_) => StatusCode::NOT_FOUND,
238            ApiError::Forbidden => StatusCode::FORBIDDEN,
239            ApiError::InsufficientScope => StatusCode::FORBIDDEN,
240            ApiError::IdempotencyKeyConflict(_) => StatusCode::CONFLICT,
241            ApiError::MonthlyBudgetExceeded(_) => StatusCode::TOO_MANY_REQUESTS,
242            ApiError::ArtifactNotFound(_) => StatusCode::NOT_FOUND,
243            ApiError::ArtifactStorageUnavailable => StatusCode::NOT_IMPLEMENTED,
244            ApiError::ArtifactTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
245            ApiError::ScheduleNotFound(_) => StatusCode::NOT_FOUND,
246            ApiError::DelegationNotFound(_) => StatusCode::NOT_FOUND,
247            ApiError::ProviderAccountNotFound(_) => StatusCode::NOT_FOUND,
248            ApiError::AccountCredentialRejected(_) => StatusCode::UNPROCESSABLE_ENTITY,
249            ApiError::Store(StoreError::Crypto(_)) => StatusCode::NOT_IMPLEMENTED,
250            ApiError::Store(StoreError::DuplicateArtifact { .. }) => StatusCode::CONFLICT,
251            ApiError::Store(StoreError::LeaseLost { .. }) => StatusCode::CONFLICT,
252            ApiError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
253            ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
254            ApiError::BadGateway(_) => StatusCode::BAD_GATEWAY,
255            ApiError::RegistryNotFound => StatusCode::NOT_FOUND,
256            ApiError::RegistryUnreachable => StatusCode::BAD_GATEWAY,
257            ApiError::InvalidInput(_) => StatusCode::UNPROCESSABLE_ENTITY,
258        }
259    }
260
261    /// Structured context attached to the JSON error body, if any.
262    ///
263    /// Never carries internal detail: only identifiers the caller is already
264    /// entitled to see.
265    fn details(&self) -> Option<Value> {
266        match self {
267            ApiError::IdempotencyKeyConflict(run_id) => Some(json!({ "run_id": run_id })),
268            ApiError::InvalidInput(errors) => Some(json!({ "errors": errors })),
269            _ => None,
270        }
271    }
272}
273
274impl IntoResponse for ApiError {
275    fn into_response(self) -> Response {
276        let status = self.status();
277        let code = self.code().to_string();
278        let details = self.details();
279
280        let message = match &self {
281            ApiError::Store(StoreError::Crypto(_)) => {
282                "secret store not configured (set IRONFLOW_SECRET_KEYS)".to_string()
283            }
284            // Carries the caller-facing detail in its own Display impl,
285            // unlike the generic "database error" of ApiError::Store.
286            ApiError::Store(e @ StoreError::LeaseLost { .. }) => e.to_string(),
287            _ => self.to_string(),
288        };
289
290        match &self {
291            // A lost lease is a client-side condition, not a server fault:
292            // it must not page anyone.
293            ApiError::Store(e @ StoreError::LeaseLost { .. }) => {
294                warn!(error = %e, code = %code, "lease refused")
295            }
296            ApiError::Store(e) => error!(error = %e, code = %code, "store error"),
297            ApiError::Internal(detail) => {
298                error!(detail = %detail, code = %code, "internal error")
299            }
300            _ => {}
301        }
302
303        let envelope = ErrorEnvelope {
304            code,
305            message,
306            details,
307        };
308
309        (status, Json(json!({ "error": envelope }))).into_response()
310    }
311}
312
313#[cfg(test)]
314mod tests {
315    use super::*;
316
317    #[test]
318    fn run_not_found_code() {
319        let err = ApiError::RunNotFound(Uuid::nil());
320        assert_eq!(err.code(), "RUN_NOT_FOUND");
321    }
322
323    #[test]
324    fn run_not_found_status() {
325        let err = ApiError::RunNotFound(Uuid::nil());
326        assert_eq!(err.status(), StatusCode::NOT_FOUND);
327    }
328
329    #[test]
330    fn bad_request_status() {
331        let err = ApiError::BadRequest("invalid field".to_string());
332        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
333        assert_eq!(err.code(), "BAD_REQUEST");
334    }
335
336    #[test]
337    fn conflict_status() {
338        let err = ApiError::Conflict("run is already waiting for a retry".to_string());
339        assert_eq!(err.status(), StatusCode::CONFLICT);
340        assert_eq!(err.code(), "CONFLICT");
341    }
342
343    #[test]
344    fn invalid_input_status_and_details() {
345        let err = ApiError::InvalidInput(vec!["\"answers\" is a required property".to_string()]);
346        assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
347        assert_eq!(err.code(), "INVALID_INPUT");
348        assert_eq!(
349            err.details(),
350            Some(json!({ "errors": ["\"answers\" is a required property"] }))
351        );
352    }
353
354    #[test]
355    fn internal_error_status() {
356        let err = ApiError::Internal("something went wrong".to_string());
357        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
358        assert_eq!(err.code(), "INTERNAL_ERROR");
359    }
360
361    #[test]
362    fn error_to_response() {
363        let err = ApiError::BadRequest("invalid input".to_string());
364        let response = err.into_response();
365        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
366    }
367
368    #[test]
369    fn unauthorized_status() {
370        let err = ApiError::Unauthorized;
371        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
372        assert_eq!(err.code(), "UNAUTHORIZED");
373    }
374
375    #[test]
376    fn invalid_credentials_status() {
377        let err = ApiError::InvalidCredentials;
378        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
379        assert_eq!(err.code(), "INVALID_CREDENTIALS");
380    }
381
382    #[test]
383    fn duplicate_email_status() {
384        let err = ApiError::DuplicateEmail;
385        assert_eq!(err.status(), StatusCode::CONFLICT);
386        assert_eq!(err.code(), "DUPLICATE_EMAIL");
387    }
388
389    #[test]
390    fn duplicate_username_status() {
391        let err = ApiError::DuplicateUsername;
392        assert_eq!(err.status(), StatusCode::CONFLICT);
393        assert_eq!(err.code(), "DUPLICATE_USERNAME");
394    }
395
396    #[test]
397    fn weak_password_status_carries_the_rule() {
398        let err = ApiError::from(PasswordPolicyError::TooCommon);
399        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
400        assert_eq!(err.code(), "WEAK_PASSWORD");
401        assert_eq!(err.to_string(), PasswordPolicyError::TooCommon.to_string());
402    }
403
404    #[test]
405    fn workflow_not_found_status() {
406        let err = ApiError::WorkflowNotFound("test".to_string());
407        assert_eq!(err.status(), StatusCode::NOT_FOUND);
408        assert_eq!(err.code(), "WORKFLOW_NOT_FOUND");
409    }
410
411    #[test]
412    fn step_not_found_status() {
413        let err = ApiError::StepNotFound(Uuid::nil());
414        assert_eq!(err.status(), StatusCode::NOT_FOUND);
415        assert_eq!(err.code(), "STEP_NOT_FOUND");
416    }
417
418    #[test]
419    fn user_not_found_status() {
420        let err = ApiError::UserNotFound(Uuid::nil());
421        assert_eq!(err.status(), StatusCode::NOT_FOUND);
422        assert_eq!(err.code(), "USER_NOT_FOUND");
423    }
424
425    #[test]
426    fn forbidden_status() {
427        let err = ApiError::Forbidden;
428        assert_eq!(err.status(), StatusCode::FORBIDDEN);
429        assert_eq!(err.code(), "FORBIDDEN");
430    }
431
432    #[test]
433    fn secret_not_found_status() {
434        let err = ApiError::SecretNotFound("demo/api-key".to_string());
435        assert_eq!(err.status(), StatusCode::NOT_FOUND);
436        assert_eq!(err.code(), "SECRET_NOT_FOUND");
437    }
438
439    #[test]
440    fn delegation_not_found_status_and_code() {
441        let err = ApiError::DelegationNotFound(Uuid::nil());
442        assert_eq!(err.status(), StatusCode::NOT_FOUND);
443        assert_eq!(err.code(), "DELEGATION_NOT_FOUND");
444    }
445
446    #[test]
447    fn provider_account_errors_status_and_code() {
448        let err = ApiError::ProviderAccountNotFound("perso".to_string());
449        assert_eq!(err.status(), StatusCode::NOT_FOUND);
450        assert_eq!(err.code(), "PROVIDER_ACCOUNT_NOT_FOUND");
451
452        let err = ApiError::AccountCredentialRejected("token rejected".to_string());
453        assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
454        assert_eq!(err.code(), "ACCOUNT_CREDENTIAL_REJECTED");
455        assert_eq!(err.to_string(), "token rejected");
456    }
457
458    #[test]
459    fn provider_account_store_errors_map_to_http() {
460        let id = Uuid::now_v7();
461        let err = ApiError::from(StoreError::ProviderAccountNotFound(id));
462        assert_eq!(err.status(), StatusCode::NOT_FOUND);
463        assert!(err.to_string().contains(&id.to_string()));
464
465        let err = ApiError::from(StoreError::DuplicateProviderAccount("perso".to_string()));
466        assert_eq!(err.status(), StatusCode::CONFLICT);
467        assert!(err.to_string().contains("perso"));
468    }
469
470    #[test]
471    fn monthly_budget_exceeded_status_and_code() {
472        let err = ApiError::MonthlyBudgetExceeded("quota exhausted".to_string());
473        assert_eq!(err.status(), StatusCode::TOO_MANY_REQUESTS);
474        assert_eq!(err.code(), "MONTHLY_BUDGET_EXCEEDED");
475        assert_eq!(err.to_string(), "quota exhausted");
476    }
477}