1use 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#[derive(Debug, Error)]
33pub enum ApiError {
34 #[error("run not found")]
36 RunNotFound(Uuid),
37
38 #[error("step not found")]
40 StepNotFound(Uuid),
41
42 #[error("workflow not found")]
44 WorkflowNotFound(String),
45
46 #[error("{0}")]
48 BadRequest(String),
49
50 #[error("{0}")]
52 Conflict(String),
53
54 #[error("authentication required")]
56 Unauthorized,
57
58 #[error("invalid credentials")]
60 InvalidCredentials,
61
62 #[error("email already exists")]
64 DuplicateEmail,
65
66 #[error("username already exists")]
68 DuplicateUsername,
69
70 #[error("{0}")]
74 WeakPassword(#[from] PasswordPolicyError),
75
76 #[error("API key not found")]
78 ApiKeyNotFound(Uuid),
79
80 #[error("user not found")]
82 UserNotFound(Uuid),
83
84 #[error("insufficient permissions")]
86 Forbidden,
87
88 #[error("secret not found")]
90 SecretNotFound(String),
91
92 #[error("insufficient scope")]
94 InsufficientScope,
95
96 #[error("idempotency key already used with a different request")]
100 IdempotencyKeyConflict(Uuid),
101 #[error("concurrency key {key:?} is held by active run {run_id}")]
105 ConcurrencyConflict {
106 key: String,
108 run_id: Uuid,
110 },
111 #[error("{0}")]
115 MonthlyBudgetExceeded(String),
116
117 #[error("artifact not found")]
119 ArtifactNotFound(String),
120
121 #[error("artifact storage is not configured")]
126 ArtifactStorageUnavailable,
127
128 #[error("artifact exceeds the size limit")]
130 ArtifactTooLarge,
131
132 #[error("schedule not found")]
134 ScheduleNotFound(Uuid),
135
136 #[error("approval delegation not found")]
138 DelegationNotFound(Uuid),
139
140 #[error("provider account not found: {0}")]
142 ProviderAccountNotFound(String),
143
144 #[error("{0}")]
146 AccountCredentialRejected(String),
147
148 #[error("database error")]
150 Store(StoreError),
151
152 #[error("internal server error")]
154 Internal(String),
155
156 #[error("upstream service unavailable")]
158 BadGateway(String),
159
160 #[error("template registry not found, check IRONFLOW_REGISTRY_URL")]
162 RegistryNotFound,
163
164 #[error("template registry unreachable")]
166 RegistryUnreachable,
167
168 #[error("input does not match the expected schema")]
173 InvalidInput(Vec<String>),
174}
175
176impl From<StoreError> for ApiError {
177 fn from(e: StoreError) -> Self {
178 match e {
179 StoreError::ScheduleNotFound(id) => ApiError::ScheduleNotFound(id),
180 StoreError::DelegationNotFound(id) => ApiError::DelegationNotFound(id),
181 StoreError::ProviderAccountNotFound(id) => {
182 ApiError::ProviderAccountNotFound(id.to_string())
183 }
184 StoreError::DuplicateProviderAccount(name) => {
185 ApiError::Conflict(format!("provider account '{name}' already exists"))
186 }
187 StoreError::ConcurrencyConflict { key, run_id } => {
188 ApiError::ConcurrencyConflict { key, run_id }
189 }
190 StoreError::InvalidConcurrencyLimit(e) => ApiError::BadRequest(e.to_string()),
191 other => ApiError::Store(other),
192 }
193 }
194}
195
196impl ApiError {
197 fn code(&self) -> &str {
199 match self {
200 ApiError::RunNotFound(_) => "RUN_NOT_FOUND",
201 ApiError::StepNotFound(_) => "STEP_NOT_FOUND",
202 ApiError::WorkflowNotFound(_) => "WORKFLOW_NOT_FOUND",
203 ApiError::BadRequest(_) => "BAD_REQUEST",
204 ApiError::Conflict(_) => "CONFLICT",
205 ApiError::Unauthorized => "UNAUTHORIZED",
206 ApiError::InvalidCredentials => "INVALID_CREDENTIALS",
207 ApiError::DuplicateEmail => "DUPLICATE_EMAIL",
208 ApiError::DuplicateUsername => "DUPLICATE_USERNAME",
209 ApiError::WeakPassword(_) => "WEAK_PASSWORD",
210 ApiError::ApiKeyNotFound(_) => "API_KEY_NOT_FOUND",
211 ApiError::UserNotFound(_) => "USER_NOT_FOUND",
212 ApiError::SecretNotFound(_) => "SECRET_NOT_FOUND",
213 ApiError::Forbidden => "FORBIDDEN",
214 ApiError::InsufficientScope => "INSUFFICIENT_SCOPE",
215 ApiError::IdempotencyKeyConflict(_) => "IDEMPOTENCY_KEY_CONFLICT",
216 ApiError::ConcurrencyConflict { .. } => CONCURRENCY_CONFLICT_CODE,
217 ApiError::MonthlyBudgetExceeded(_) => MONTHLY_BUDGET_EXCEEDED_CODE,
218 ApiError::ArtifactNotFound(_) => "ARTIFACT_NOT_FOUND",
219 ApiError::ArtifactStorageUnavailable => "ARTIFACT_STORAGE_UNAVAILABLE",
220 ApiError::ArtifactTooLarge => "ARTIFACT_TOO_LARGE",
221 ApiError::ScheduleNotFound(_) => "SCHEDULE_NOT_FOUND",
222 ApiError::DelegationNotFound(_) => "DELEGATION_NOT_FOUND",
223 ApiError::ProviderAccountNotFound(_) => "PROVIDER_ACCOUNT_NOT_FOUND",
224 ApiError::AccountCredentialRejected(_) => "ACCOUNT_CREDENTIAL_REJECTED",
225 ApiError::Store(StoreError::Crypto(_)) => "SECRET_STORE_UNAVAILABLE",
226 ApiError::Store(StoreError::DuplicateArtifact { .. }) => "DUPLICATE_ARTIFACT",
227 ApiError::Store(StoreError::LeaseLost { .. }) => "LEASE_LOST",
228 ApiError::Store(_) => "DATABASE_ERROR",
229 ApiError::Internal(_) => "INTERNAL_ERROR",
230 ApiError::BadGateway(_) => "BAD_GATEWAY",
231 ApiError::RegistryNotFound => "REGISTRY_NOT_FOUND",
232 ApiError::RegistryUnreachable => "REGISTRY_UNREACHABLE",
233 ApiError::InvalidInput(_) => "INVALID_INPUT",
234 }
235 }
236
237 fn status(&self) -> StatusCode {
239 match self {
240 ApiError::RunNotFound(_) => StatusCode::NOT_FOUND,
241 ApiError::StepNotFound(_) => StatusCode::NOT_FOUND,
242 ApiError::WorkflowNotFound(_) => StatusCode::NOT_FOUND,
243 ApiError::SecretNotFound(_) => StatusCode::NOT_FOUND,
244 ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
245 ApiError::Conflict(_) => StatusCode::CONFLICT,
246 ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
247 ApiError::InvalidCredentials => StatusCode::UNAUTHORIZED,
248 ApiError::DuplicateEmail => StatusCode::CONFLICT,
249 ApiError::DuplicateUsername => StatusCode::CONFLICT,
250 ApiError::WeakPassword(_) => StatusCode::BAD_REQUEST,
251 ApiError::ApiKeyNotFound(_) => StatusCode::NOT_FOUND,
252 ApiError::UserNotFound(_) => StatusCode::NOT_FOUND,
253 ApiError::Forbidden => StatusCode::FORBIDDEN,
254 ApiError::InsufficientScope => StatusCode::FORBIDDEN,
255 ApiError::IdempotencyKeyConflict(_) => StatusCode::CONFLICT,
256 ApiError::ConcurrencyConflict { .. } => StatusCode::CONFLICT,
257 ApiError::MonthlyBudgetExceeded(_) => StatusCode::TOO_MANY_REQUESTS,
258 ApiError::ArtifactNotFound(_) => StatusCode::NOT_FOUND,
259 ApiError::ArtifactStorageUnavailable => StatusCode::NOT_IMPLEMENTED,
260 ApiError::ArtifactTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
261 ApiError::ScheduleNotFound(_) => StatusCode::NOT_FOUND,
262 ApiError::DelegationNotFound(_) => StatusCode::NOT_FOUND,
263 ApiError::ProviderAccountNotFound(_) => StatusCode::NOT_FOUND,
264 ApiError::AccountCredentialRejected(_) => StatusCode::UNPROCESSABLE_ENTITY,
265 ApiError::Store(StoreError::Crypto(_)) => StatusCode::NOT_IMPLEMENTED,
266 ApiError::Store(StoreError::DuplicateArtifact { .. }) => StatusCode::CONFLICT,
267 ApiError::Store(StoreError::LeaseLost { .. }) => StatusCode::CONFLICT,
268 ApiError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
269 ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
270 ApiError::BadGateway(_) => StatusCode::BAD_GATEWAY,
271 ApiError::RegistryNotFound => StatusCode::NOT_FOUND,
272 ApiError::RegistryUnreachable => StatusCode::BAD_GATEWAY,
273 ApiError::InvalidInput(_) => StatusCode::UNPROCESSABLE_ENTITY,
274 }
275 }
276
277 fn details(&self) -> Option<Value> {
282 match self {
283 ApiError::IdempotencyKeyConflict(run_id) => Some(json!({ "run_id": run_id })),
284 ApiError::ConcurrencyConflict { key, run_id } => {
285 Some(json!({ "key": key, "run_id": run_id }))
286 }
287 ApiError::InvalidInput(errors) => Some(json!({ "errors": errors })),
288 _ => None,
289 }
290 }
291}
292
293impl IntoResponse for ApiError {
294 fn into_response(self) -> Response {
295 let status = self.status();
296 let code = self.code().to_string();
297 let details = self.details();
298
299 let message = match &self {
300 ApiError::Store(StoreError::Crypto(_)) => {
301 "secret store not configured (set IRONFLOW_SECRET_KEYS)".to_string()
302 }
303 ApiError::Store(e @ StoreError::LeaseLost { .. }) => e.to_string(),
306 _ => self.to_string(),
307 };
308
309 match &self {
310 ApiError::Store(e @ StoreError::LeaseLost { .. }) => {
313 warn!(error = %e, code = %code, "lease refused")
314 }
315 ApiError::Store(e) => error!(error = %e, code = %code, "store error"),
316 ApiError::Internal(detail) => {
317 error!(detail = %detail, code = %code, "internal error")
318 }
319 _ => {}
320 }
321
322 let envelope = ErrorEnvelope {
323 code,
324 message,
325 details,
326 };
327
328 (status, Json(json!({ "error": envelope }))).into_response()
329 }
330}
331
332#[cfg(test)]
333mod tests {
334 use ironflow_store::models::ConcurrencyLimitError;
335
336 use super::*;
337
338 #[test]
339 fn run_not_found_code() {
340 let err = ApiError::RunNotFound(Uuid::nil());
341 assert_eq!(err.code(), "RUN_NOT_FOUND");
342 }
343
344 #[test]
345 fn run_not_found_status() {
346 let err = ApiError::RunNotFound(Uuid::nil());
347 assert_eq!(err.status(), StatusCode::NOT_FOUND);
348 }
349
350 #[test]
351 fn bad_request_status() {
352 let err = ApiError::BadRequest("invalid field".to_string());
353 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
354 assert_eq!(err.code(), "BAD_REQUEST");
355 }
356
357 #[test]
358 fn conflict_status() {
359 let err = ApiError::Conflict("run is already waiting for a retry".to_string());
360 assert_eq!(err.status(), StatusCode::CONFLICT);
361 assert_eq!(err.code(), "CONFLICT");
362 }
363
364 #[test]
365 fn invalid_input_status_and_details() {
366 let err = ApiError::InvalidInput(vec!["\"answers\" is a required property".to_string()]);
367 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
368 assert_eq!(err.code(), "INVALID_INPUT");
369 assert_eq!(
370 err.details(),
371 Some(json!({ "errors": ["\"answers\" is a required property"] }))
372 );
373 }
374
375 #[test]
376 fn internal_error_status() {
377 let err = ApiError::Internal("something went wrong".to_string());
378 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
379 assert_eq!(err.code(), "INTERNAL_ERROR");
380 }
381
382 #[test]
383 fn error_to_response() {
384 let err = ApiError::BadRequest("invalid input".to_string());
385 let response = err.into_response();
386 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
387 }
388
389 #[test]
390 fn unauthorized_status() {
391 let err = ApiError::Unauthorized;
392 assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
393 assert_eq!(err.code(), "UNAUTHORIZED");
394 }
395
396 #[test]
397 fn invalid_credentials_status() {
398 let err = ApiError::InvalidCredentials;
399 assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
400 assert_eq!(err.code(), "INVALID_CREDENTIALS");
401 }
402
403 #[test]
404 fn duplicate_email_status() {
405 let err = ApiError::DuplicateEmail;
406 assert_eq!(err.status(), StatusCode::CONFLICT);
407 assert_eq!(err.code(), "DUPLICATE_EMAIL");
408 }
409
410 #[test]
411 fn duplicate_username_status() {
412 let err = ApiError::DuplicateUsername;
413 assert_eq!(err.status(), StatusCode::CONFLICT);
414 assert_eq!(err.code(), "DUPLICATE_USERNAME");
415 }
416
417 #[test]
418 fn weak_password_status_carries_the_rule() {
419 let err = ApiError::from(PasswordPolicyError::TooCommon);
420 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
421 assert_eq!(err.code(), "WEAK_PASSWORD");
422 assert_eq!(err.to_string(), PasswordPolicyError::TooCommon.to_string());
423 }
424
425 #[test]
426 fn workflow_not_found_status() {
427 let err = ApiError::WorkflowNotFound("test".to_string());
428 assert_eq!(err.status(), StatusCode::NOT_FOUND);
429 assert_eq!(err.code(), "WORKFLOW_NOT_FOUND");
430 }
431
432 #[test]
433 fn step_not_found_status() {
434 let err = ApiError::StepNotFound(Uuid::nil());
435 assert_eq!(err.status(), StatusCode::NOT_FOUND);
436 assert_eq!(err.code(), "STEP_NOT_FOUND");
437 }
438
439 #[test]
440 fn user_not_found_status() {
441 let err = ApiError::UserNotFound(Uuid::nil());
442 assert_eq!(err.status(), StatusCode::NOT_FOUND);
443 assert_eq!(err.code(), "USER_NOT_FOUND");
444 }
445
446 #[test]
447 fn forbidden_status() {
448 let err = ApiError::Forbidden;
449 assert_eq!(err.status(), StatusCode::FORBIDDEN);
450 assert_eq!(err.code(), "FORBIDDEN");
451 }
452
453 #[test]
454 fn secret_not_found_status() {
455 let err = ApiError::SecretNotFound("demo/api-key".to_string());
456 assert_eq!(err.status(), StatusCode::NOT_FOUND);
457 assert_eq!(err.code(), "SECRET_NOT_FOUND");
458 }
459
460 #[test]
461 fn delegation_not_found_status_and_code() {
462 let err = ApiError::DelegationNotFound(Uuid::nil());
463 assert_eq!(err.status(), StatusCode::NOT_FOUND);
464 assert_eq!(err.code(), "DELEGATION_NOT_FOUND");
465 }
466
467 #[test]
468 fn provider_account_errors_status_and_code() {
469 let err = ApiError::ProviderAccountNotFound("perso".to_string());
470 assert_eq!(err.status(), StatusCode::NOT_FOUND);
471 assert_eq!(err.code(), "PROVIDER_ACCOUNT_NOT_FOUND");
472
473 let err = ApiError::AccountCredentialRejected("token rejected".to_string());
474 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
475 assert_eq!(err.code(), "ACCOUNT_CREDENTIAL_REJECTED");
476 assert_eq!(err.to_string(), "token rejected");
477 }
478
479 #[test]
480 fn provider_account_store_errors_map_to_http() {
481 let id = Uuid::now_v7();
482 let err = ApiError::from(StoreError::ProviderAccountNotFound(id));
483 assert_eq!(err.status(), StatusCode::NOT_FOUND);
484 assert!(err.to_string().contains(&id.to_string()));
485
486 let err = ApiError::from(StoreError::DuplicateProviderAccount("perso".to_string()));
487 assert_eq!(err.status(), StatusCode::CONFLICT);
488 assert!(err.to_string().contains("perso"));
489 }
490
491 #[test]
492 fn concurrency_conflict_status_code_and_details() {
493 let run_id = Uuid::now_v7();
494 let err = ApiError::from(StoreError::ConcurrencyConflict {
495 key: "issue:12".to_string(),
496 run_id,
497 });
498 assert_eq!(err.status(), StatusCode::CONFLICT);
499 assert_eq!(err.code(), "CONCURRENCY_CONFLICT");
500 assert_eq!(
501 err.details(),
502 Some(json!({ "key": "issue:12", "run_id": run_id }))
503 );
504 assert!(err.to_string().contains(&run_id.to_string()));
505 }
506
507 #[test]
508 fn invalid_concurrency_limit_maps_to_bad_request() {
509 let err = ApiError::from(StoreError::InvalidConcurrencyLimit(
510 ConcurrencyLimitError::ZeroLimit {
511 group: "repo:acme".to_string(),
512 },
513 ));
514 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
515 assert_eq!(err.code(), "BAD_REQUEST");
516 assert_eq!(
517 err.to_string(),
518 "concurrency limit for group 'repo:acme' must be at least 1"
519 );
520 }
521
522 #[test]
523 fn monthly_budget_exceeded_status_and_code() {
524 let err = ApiError::MonthlyBudgetExceeded("quota exhausted".to_string());
525 assert_eq!(err.status(), StatusCode::TOO_MANY_REQUESTS);
526 assert_eq!(err.code(), "MONTHLY_BUDGET_EXCEEDED");
527 assert_eq!(err.to_string(), "quota exhausted");
528 }
529}