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("agent role '{0}' not found")]
150 AgentRoleNotFound(String),
151
152 #[error("{0}")]
154 AccountCredentialRejected(String),
155
156 #[error("database error")]
158 Store(StoreError),
159
160 #[error("internal server error")]
162 Internal(String),
163
164 #[error("upstream service unavailable")]
166 BadGateway(String),
167
168 #[error("template registry not found, check IRONFLOW_REGISTRY_URL")]
170 RegistryNotFound,
171
172 #[error("template registry unreachable")]
174 RegistryUnreachable,
175
176 #[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 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 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 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 ApiError::Store(e @ StoreError::LeaseLost { .. }) => e.to_string(),
320 _ => self.to_string(),
321 };
322
323 match &self {
324 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}