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::{CONCURRENCY_CONFLICT_CODE, 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    /// A non-terminal run already holds the requested concurrency key (409).
102    ///
103    /// Carries the key and the run holding it so the client can follow it.
104    #[error("concurrency key {key:?} is held by active run {run_id}")]
105    ConcurrencyConflict {
106        /// The contested key.
107        key: String,
108        /// The run holding the key.
109        run_id: Uuid,
110    },
111    /// The global monthly cost quota is exhausted (429).
112    ///
113    /// Only blocks the creation of new runs; runs already in flight continue.
114    #[error("{0}")]
115    MonthlyBudgetExceeded(String),
116
117    /// Too many simultaneous event streams for the caller or the server (429).
118    #[error("too many open event streams")]
119    SseConnectionLimit,
120
121    /// The requested artifact does not exist (404).
122    #[error("artifact not found")]
123    ArtifactNotFound(String),
124
125    /// No artifact storage backend is configured (501).
126    ///
127    /// Every other endpoint keeps working: an upgraded deployment that has not
128    /// opted into artifacts is degraded here only.
129    #[error("artifact storage is not configured")]
130    ArtifactStorageUnavailable,
131
132    /// The uploaded payload exceeded the artifact size limit (413).
133    #[error("artifact exceeds the size limit")]
134    ArtifactTooLarge,
135
136    /// Schedule not found (404).
137    #[error("schedule not found")]
138    ScheduleNotFound(Uuid),
139
140    /// Approval delegation not found (404).
141    #[error("approval delegation not found")]
142    DelegationNotFound(Uuid),
143
144    /// Provider Account not found (404), by id or name.
145    #[error("provider account not found: {0}")]
146    ProviderAccountNotFound(String),
147
148    /// Agent role never seen by the store (404).
149    #[error("agent role '{0}' not found")]
150    AgentRoleNotFound(String),
151
152    /// The account credential was rejected, by format or by the provider (422).
153    #[error("{0}")]
154    AccountCredentialRejected(String),
155
156    /// Store operation failed (500).
157    #[error("database error")]
158    Store(StoreError),
159
160    /// Internal server error (500).
161    #[error("internal server error")]
162    Internal(String),
163
164    /// An upstream service is unreachable or returned an error (502).
165    #[error("upstream service unavailable")]
166    BadGateway(String),
167
168    /// No template registry exists at the configured URL (404).
169    #[error("template registry not found, check IRONFLOW_REGISTRY_URL")]
170    RegistryNotFound,
171
172    /// The template registry host cannot be reached (502).
173    #[error("template registry unreachable")]
174    RegistryUnreachable,
175
176    /// A submitted value does not match the expected JSON schema (422).
177    ///
178    /// Carries one human-readable message per violation, returned to the
179    /// caller under `details.errors`.
180    #[error("input does not match the expected schema")]
181    InvalidInput(Vec<String>),
182}
183
184impl From<StoreError> for ApiError {
185    fn from(e: StoreError) -> Self {
186        match e {
187            StoreError::ScheduleNotFound(id) => ApiError::ScheduleNotFound(id),
188            StoreError::DelegationNotFound(id) => ApiError::DelegationNotFound(id),
189            StoreError::ProviderAccountNotFound(id) => {
190                ApiError::ProviderAccountNotFound(id.to_string())
191            }
192            StoreError::AgentRoleNotFound(name) => ApiError::AgentRoleNotFound(name),
193            StoreError::DuplicateProviderAccount(name) => {
194                ApiError::Conflict(format!("provider account '{name}' already exists"))
195            }
196            StoreError::ConcurrencyConflict { key, run_id } => {
197                ApiError::ConcurrencyConflict { key, run_id }
198            }
199            StoreError::InvalidConcurrencyLimit(e) => ApiError::BadRequest(e.to_string()),
200            StoreError::InvalidWorkerTag(e) => ApiError::BadRequest(e.to_string()),
201            other => ApiError::Store(other),
202        }
203    }
204}
205
206impl ApiError {
207    /// Return the error code for JSON serialization.
208    fn code(&self) -> &str {
209        match self {
210            ApiError::RunNotFound(_) => "RUN_NOT_FOUND",
211            ApiError::StepNotFound(_) => "STEP_NOT_FOUND",
212            ApiError::WorkflowNotFound(_) => "WORKFLOW_NOT_FOUND",
213            ApiError::BadRequest(_) => "BAD_REQUEST",
214            ApiError::Conflict(_) => "CONFLICT",
215            ApiError::Unauthorized => "UNAUTHORIZED",
216            ApiError::InvalidCredentials => "INVALID_CREDENTIALS",
217            ApiError::DuplicateEmail => "DUPLICATE_EMAIL",
218            ApiError::DuplicateUsername => "DUPLICATE_USERNAME",
219            ApiError::WeakPassword(_) => "WEAK_PASSWORD",
220            ApiError::ApiKeyNotFound(_) => "API_KEY_NOT_FOUND",
221            ApiError::UserNotFound(_) => "USER_NOT_FOUND",
222            ApiError::SecretNotFound(_) => "SECRET_NOT_FOUND",
223            ApiError::Forbidden => "FORBIDDEN",
224            ApiError::InsufficientScope => "INSUFFICIENT_SCOPE",
225            ApiError::IdempotencyKeyConflict(_) => "IDEMPOTENCY_KEY_CONFLICT",
226            ApiError::ConcurrencyConflict { .. } => CONCURRENCY_CONFLICT_CODE,
227            ApiError::MonthlyBudgetExceeded(_) => MONTHLY_BUDGET_EXCEEDED_CODE,
228            ApiError::SseConnectionLimit => "SSE_CONNECTION_LIMIT",
229            ApiError::ArtifactNotFound(_) => "ARTIFACT_NOT_FOUND",
230            ApiError::ArtifactStorageUnavailable => "ARTIFACT_STORAGE_UNAVAILABLE",
231            ApiError::ArtifactTooLarge => "ARTIFACT_TOO_LARGE",
232            ApiError::ScheduleNotFound(_) => "SCHEDULE_NOT_FOUND",
233            ApiError::DelegationNotFound(_) => "DELEGATION_NOT_FOUND",
234            ApiError::ProviderAccountNotFound(_) => "PROVIDER_ACCOUNT_NOT_FOUND",
235            ApiError::AgentRoleNotFound(_) => "AGENT_ROLE_NOT_FOUND",
236            ApiError::AccountCredentialRejected(_) => "ACCOUNT_CREDENTIAL_REJECTED",
237            ApiError::Store(StoreError::Crypto(_)) => "SECRET_STORE_UNAVAILABLE",
238            ApiError::Store(StoreError::DuplicateArtifact { .. }) => "DUPLICATE_ARTIFACT",
239            ApiError::Store(StoreError::LeaseLost { .. }) => "LEASE_LOST",
240            ApiError::Store(_) => "DATABASE_ERROR",
241            ApiError::Internal(_) => "INTERNAL_ERROR",
242            ApiError::BadGateway(_) => "BAD_GATEWAY",
243            ApiError::RegistryNotFound => "REGISTRY_NOT_FOUND",
244            ApiError::RegistryUnreachable => "REGISTRY_UNREACHABLE",
245            ApiError::InvalidInput(_) => "INVALID_INPUT",
246        }
247    }
248
249    /// Return the HTTP status code for this error.
250    fn status(&self) -> StatusCode {
251        match self {
252            ApiError::RunNotFound(_) => StatusCode::NOT_FOUND,
253            ApiError::StepNotFound(_) => StatusCode::NOT_FOUND,
254            ApiError::WorkflowNotFound(_) => StatusCode::NOT_FOUND,
255            ApiError::SecretNotFound(_) => StatusCode::NOT_FOUND,
256            ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
257            ApiError::Conflict(_) => StatusCode::CONFLICT,
258            ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
259            ApiError::InvalidCredentials => StatusCode::UNAUTHORIZED,
260            ApiError::DuplicateEmail => StatusCode::CONFLICT,
261            ApiError::DuplicateUsername => StatusCode::CONFLICT,
262            ApiError::WeakPassword(_) => StatusCode::BAD_REQUEST,
263            ApiError::ApiKeyNotFound(_) => StatusCode::NOT_FOUND,
264            ApiError::UserNotFound(_) => StatusCode::NOT_FOUND,
265            ApiError::Forbidden => StatusCode::FORBIDDEN,
266            ApiError::InsufficientScope => StatusCode::FORBIDDEN,
267            ApiError::IdempotencyKeyConflict(_) => StatusCode::CONFLICT,
268            ApiError::ConcurrencyConflict { .. } => StatusCode::CONFLICT,
269            ApiError::MonthlyBudgetExceeded(_) => StatusCode::TOO_MANY_REQUESTS,
270            ApiError::SseConnectionLimit => StatusCode::TOO_MANY_REQUESTS,
271            ApiError::ArtifactNotFound(_) => StatusCode::NOT_FOUND,
272            ApiError::ArtifactStorageUnavailable => StatusCode::NOT_IMPLEMENTED,
273            ApiError::ArtifactTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
274            ApiError::ScheduleNotFound(_) => StatusCode::NOT_FOUND,
275            ApiError::DelegationNotFound(_) => StatusCode::NOT_FOUND,
276            ApiError::ProviderAccountNotFound(_) => StatusCode::NOT_FOUND,
277            ApiError::AgentRoleNotFound(_) => StatusCode::NOT_FOUND,
278            ApiError::AccountCredentialRejected(_) => StatusCode::UNPROCESSABLE_ENTITY,
279            ApiError::Store(StoreError::Crypto(_)) => StatusCode::NOT_IMPLEMENTED,
280            ApiError::Store(StoreError::DuplicateArtifact { .. }) => StatusCode::CONFLICT,
281            ApiError::Store(StoreError::LeaseLost { .. }) => StatusCode::CONFLICT,
282            ApiError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
283            ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
284            ApiError::BadGateway(_) => StatusCode::BAD_GATEWAY,
285            ApiError::RegistryNotFound => StatusCode::NOT_FOUND,
286            ApiError::RegistryUnreachable => StatusCode::BAD_GATEWAY,
287            ApiError::InvalidInput(_) => StatusCode::UNPROCESSABLE_ENTITY,
288        }
289    }
290
291    /// Structured context attached to the JSON error body, if any.
292    ///
293    /// Never carries internal detail: only identifiers the caller is already
294    /// entitled to see.
295    fn details(&self) -> Option<Value> {
296        match self {
297            ApiError::IdempotencyKeyConflict(run_id) => Some(json!({ "run_id": run_id })),
298            ApiError::ConcurrencyConflict { key, run_id } => {
299                Some(json!({ "key": key, "run_id": run_id }))
300            }
301            ApiError::InvalidInput(errors) => Some(json!({ "errors": errors })),
302            _ => None,
303        }
304    }
305}
306
307impl IntoResponse for ApiError {
308    fn into_response(self) -> Response {
309        let status = self.status();
310        let code = self.code().to_string();
311        let details = self.details();
312
313        let message = match &self {
314            ApiError::Store(StoreError::Crypto(_)) => {
315                "secret store not configured (set IRONFLOW_SECRET_KEYS)".to_string()
316            }
317            // Carries the caller-facing detail in its own Display impl,
318            // unlike the generic "database error" of ApiError::Store.
319            ApiError::Store(e @ StoreError::LeaseLost { .. }) => e.to_string(),
320            _ => self.to_string(),
321        };
322
323        match &self {
324            // A lost lease is a client-side condition, not a server fault:
325            // it must not page anyone.
326            ApiError::Store(e @ StoreError::LeaseLost { .. }) => {
327                warn!(error = %e, code = %code, "lease refused")
328            }
329            ApiError::Store(e) => error!(error = %e, code = %code, "store error"),
330            ApiError::Internal(detail) => {
331                error!(detail = %detail, code = %code, "internal error")
332            }
333            _ => {}
334        }
335
336        let envelope = ErrorEnvelope {
337            code,
338            message,
339            details,
340        };
341
342        (status, Json(json!({ "error": envelope }))).into_response()
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use ironflow_store::models::ConcurrencyLimitError;
349
350    use super::*;
351
352    #[test]
353    fn run_not_found_code() {
354        let err = ApiError::RunNotFound(Uuid::nil());
355        assert_eq!(err.code(), "RUN_NOT_FOUND");
356    }
357
358    #[test]
359    fn run_not_found_status() {
360        let err = ApiError::RunNotFound(Uuid::nil());
361        assert_eq!(err.status(), StatusCode::NOT_FOUND);
362    }
363
364    #[test]
365    fn bad_request_status() {
366        let err = ApiError::BadRequest("invalid field".to_string());
367        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
368        assert_eq!(err.code(), "BAD_REQUEST");
369    }
370
371    #[test]
372    fn conflict_status() {
373        let err = ApiError::Conflict("run is already waiting for a retry".to_string());
374        assert_eq!(err.status(), StatusCode::CONFLICT);
375        assert_eq!(err.code(), "CONFLICT");
376    }
377
378    #[test]
379    fn invalid_input_status_and_details() {
380        let err = ApiError::InvalidInput(vec!["\"answers\" is a required property".to_string()]);
381        assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
382        assert_eq!(err.code(), "INVALID_INPUT");
383        assert_eq!(
384            err.details(),
385            Some(json!({ "errors": ["\"answers\" is a required property"] }))
386        );
387    }
388
389    #[test]
390    fn internal_error_status() {
391        let err = ApiError::Internal("something went wrong".to_string());
392        assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
393        assert_eq!(err.code(), "INTERNAL_ERROR");
394    }
395
396    #[test]
397    fn error_to_response() {
398        let err = ApiError::BadRequest("invalid input".to_string());
399        let response = err.into_response();
400        assert_eq!(response.status(), StatusCode::BAD_REQUEST);
401    }
402
403    #[test]
404    fn unauthorized_status() {
405        let err = ApiError::Unauthorized;
406        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
407        assert_eq!(err.code(), "UNAUTHORIZED");
408    }
409
410    #[test]
411    fn invalid_credentials_status() {
412        let err = ApiError::InvalidCredentials;
413        assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
414        assert_eq!(err.code(), "INVALID_CREDENTIALS");
415    }
416
417    #[test]
418    fn duplicate_email_status() {
419        let err = ApiError::DuplicateEmail;
420        assert_eq!(err.status(), StatusCode::CONFLICT);
421        assert_eq!(err.code(), "DUPLICATE_EMAIL");
422    }
423
424    #[test]
425    fn duplicate_username_status() {
426        let err = ApiError::DuplicateUsername;
427        assert_eq!(err.status(), StatusCode::CONFLICT);
428        assert_eq!(err.code(), "DUPLICATE_USERNAME");
429    }
430
431    #[test]
432    fn weak_password_status_carries_the_rule() {
433        let err = ApiError::from(PasswordPolicyError::TooCommon);
434        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
435        assert_eq!(err.code(), "WEAK_PASSWORD");
436        assert_eq!(err.to_string(), PasswordPolicyError::TooCommon.to_string());
437    }
438
439    #[test]
440    fn workflow_not_found_status() {
441        let err = ApiError::WorkflowNotFound("test".to_string());
442        assert_eq!(err.status(), StatusCode::NOT_FOUND);
443        assert_eq!(err.code(), "WORKFLOW_NOT_FOUND");
444    }
445
446    #[test]
447    fn step_not_found_status() {
448        let err = ApiError::StepNotFound(Uuid::nil());
449        assert_eq!(err.status(), StatusCode::NOT_FOUND);
450        assert_eq!(err.code(), "STEP_NOT_FOUND");
451    }
452
453    #[test]
454    fn user_not_found_status() {
455        let err = ApiError::UserNotFound(Uuid::nil());
456        assert_eq!(err.status(), StatusCode::NOT_FOUND);
457        assert_eq!(err.code(), "USER_NOT_FOUND");
458    }
459
460    #[test]
461    fn forbidden_status() {
462        let err = ApiError::Forbidden;
463        assert_eq!(err.status(), StatusCode::FORBIDDEN);
464        assert_eq!(err.code(), "FORBIDDEN");
465    }
466
467    #[test]
468    fn secret_not_found_status() {
469        let err = ApiError::SecretNotFound("demo/api-key".to_string());
470        assert_eq!(err.status(), StatusCode::NOT_FOUND);
471        assert_eq!(err.code(), "SECRET_NOT_FOUND");
472    }
473
474    #[test]
475    fn delegation_not_found_status_and_code() {
476        let err = ApiError::DelegationNotFound(Uuid::nil());
477        assert_eq!(err.status(), StatusCode::NOT_FOUND);
478        assert_eq!(err.code(), "DELEGATION_NOT_FOUND");
479    }
480
481    #[test]
482    fn provider_account_errors_status_and_code() {
483        let err = ApiError::ProviderAccountNotFound("perso".to_string());
484        assert_eq!(err.status(), StatusCode::NOT_FOUND);
485        assert_eq!(err.code(), "PROVIDER_ACCOUNT_NOT_FOUND");
486
487        let err = ApiError::AccountCredentialRejected("token rejected".to_string());
488        assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
489        assert_eq!(err.code(), "ACCOUNT_CREDENTIAL_REJECTED");
490        assert_eq!(err.to_string(), "token rejected");
491    }
492
493    #[test]
494    fn agent_role_not_found_maps_to_404() {
495        let err = ApiError::from(StoreError::AgentRoleNotFound("reviewer".to_string()));
496        assert_eq!(err.status(), StatusCode::NOT_FOUND);
497        assert_eq!(err.code(), "AGENT_ROLE_NOT_FOUND");
498        assert_eq!(err.to_string(), "agent role 'reviewer' not found");
499    }
500
501    #[test]
502    fn provider_account_store_errors_map_to_http() {
503        let id = Uuid::now_v7();
504        let err = ApiError::from(StoreError::ProviderAccountNotFound(id));
505        assert_eq!(err.status(), StatusCode::NOT_FOUND);
506        assert!(err.to_string().contains(&id.to_string()));
507
508        let err = ApiError::from(StoreError::DuplicateProviderAccount("perso".to_string()));
509        assert_eq!(err.status(), StatusCode::CONFLICT);
510        assert!(err.to_string().contains("perso"));
511    }
512
513    #[test]
514    fn concurrency_conflict_status_code_and_details() {
515        let run_id = Uuid::now_v7();
516        let err = ApiError::from(StoreError::ConcurrencyConflict {
517            key: "issue:12".to_string(),
518            run_id,
519        });
520        assert_eq!(err.status(), StatusCode::CONFLICT);
521        assert_eq!(err.code(), "CONCURRENCY_CONFLICT");
522        assert_eq!(
523            err.details(),
524            Some(json!({ "key": "issue:12", "run_id": run_id }))
525        );
526        assert!(err.to_string().contains(&run_id.to_string()));
527    }
528
529    #[test]
530    fn invalid_concurrency_limit_maps_to_bad_request() {
531        let err = ApiError::from(StoreError::InvalidConcurrencyLimit(
532            ConcurrencyLimitError::ZeroLimit {
533                group: "repo:acme".to_string(),
534            },
535        ));
536        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
537        assert_eq!(err.code(), "BAD_REQUEST");
538        assert_eq!(
539            err.to_string(),
540            "concurrency limit for group 'repo:acme' must be at least 1"
541        );
542    }
543
544    #[test]
545    fn monthly_budget_exceeded_status_and_code() {
546        let err = ApiError::MonthlyBudgetExceeded("quota exhausted".to_string());
547        assert_eq!(err.status(), StatusCode::TOO_MANY_REQUESTS);
548        assert_eq!(err.code(), "MONTHLY_BUDGET_EXCEEDED");
549        assert_eq!(err.to_string(), "quota exhausted");
550    }
551
552    #[test]
553    fn sse_connection_limit_status_and_code() {
554        let err = ApiError::SseConnectionLimit;
555        assert_eq!(err.status(), StatusCode::TOO_MANY_REQUESTS);
556        assert_eq!(err.code(), "SSE_CONNECTION_LIMIT");
557    }
558}