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