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