1use axum::Json;
8use axum::http::StatusCode;
9use axum::response::{IntoResponse, Response};
10use ironflow_auth::password::PasswordPolicyError;
11use ironflow_engine::error::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("{0}")]
105 MonthlyBudgetExceeded(String),
106
107 #[error("artifact not found")]
109 ArtifactNotFound(String),
110
111 #[error("artifact storage is not configured")]
116 ArtifactStorageUnavailable,
117
118 #[error("artifact exceeds the size limit")]
120 ArtifactTooLarge,
121
122 #[error("schedule not found")]
124 ScheduleNotFound(Uuid),
125
126 #[error("approval delegation not found")]
128 DelegationNotFound(Uuid),
129
130 #[error("provider account not found: {0}")]
132 ProviderAccountNotFound(String),
133
134 #[error("{0}")]
136 AccountCredentialRejected(String),
137
138 #[error("database error")]
140 Store(StoreError),
141
142 #[error("internal server error")]
144 Internal(String),
145
146 #[error("upstream service unavailable")]
148 BadGateway(String),
149
150 #[error("template registry not found, check IRONFLOW_REGISTRY_URL")]
152 RegistryNotFound,
153
154 #[error("template registry unreachable")]
156 RegistryUnreachable,
157
158 #[error("input does not match the expected schema")]
163 InvalidInput(Vec<String>),
164}
165
166impl From<StoreError> for ApiError {
167 fn from(e: StoreError) -> Self {
168 match e {
169 StoreError::ScheduleNotFound(id) => ApiError::ScheduleNotFound(id),
170 StoreError::DelegationNotFound(id) => ApiError::DelegationNotFound(id),
171 StoreError::ProviderAccountNotFound(id) => {
172 ApiError::ProviderAccountNotFound(id.to_string())
173 }
174 StoreError::DuplicateProviderAccount(name) => {
175 ApiError::Conflict(format!("provider account '{name}' already exists"))
176 }
177 other => ApiError::Store(other),
178 }
179 }
180}
181
182impl ApiError {
183 fn code(&self) -> &str {
185 match self {
186 ApiError::RunNotFound(_) => "RUN_NOT_FOUND",
187 ApiError::StepNotFound(_) => "STEP_NOT_FOUND",
188 ApiError::WorkflowNotFound(_) => "WORKFLOW_NOT_FOUND",
189 ApiError::BadRequest(_) => "BAD_REQUEST",
190 ApiError::Conflict(_) => "CONFLICT",
191 ApiError::Unauthorized => "UNAUTHORIZED",
192 ApiError::InvalidCredentials => "INVALID_CREDENTIALS",
193 ApiError::DuplicateEmail => "DUPLICATE_EMAIL",
194 ApiError::DuplicateUsername => "DUPLICATE_USERNAME",
195 ApiError::WeakPassword(_) => "WEAK_PASSWORD",
196 ApiError::ApiKeyNotFound(_) => "API_KEY_NOT_FOUND",
197 ApiError::UserNotFound(_) => "USER_NOT_FOUND",
198 ApiError::SecretNotFound(_) => "SECRET_NOT_FOUND",
199 ApiError::Forbidden => "FORBIDDEN",
200 ApiError::InsufficientScope => "INSUFFICIENT_SCOPE",
201 ApiError::IdempotencyKeyConflict(_) => "IDEMPOTENCY_KEY_CONFLICT",
202 ApiError::MonthlyBudgetExceeded(_) => MONTHLY_BUDGET_EXCEEDED_CODE,
203 ApiError::ArtifactNotFound(_) => "ARTIFACT_NOT_FOUND",
204 ApiError::ArtifactStorageUnavailable => "ARTIFACT_STORAGE_UNAVAILABLE",
205 ApiError::ArtifactTooLarge => "ARTIFACT_TOO_LARGE",
206 ApiError::ScheduleNotFound(_) => "SCHEDULE_NOT_FOUND",
207 ApiError::DelegationNotFound(_) => "DELEGATION_NOT_FOUND",
208 ApiError::ProviderAccountNotFound(_) => "PROVIDER_ACCOUNT_NOT_FOUND",
209 ApiError::AccountCredentialRejected(_) => "ACCOUNT_CREDENTIAL_REJECTED",
210 ApiError::Store(StoreError::Crypto(_)) => "SECRET_STORE_UNAVAILABLE",
211 ApiError::Store(StoreError::DuplicateArtifact { .. }) => "DUPLICATE_ARTIFACT",
212 ApiError::Store(StoreError::LeaseLost { .. }) => "LEASE_LOST",
213 ApiError::Store(_) => "DATABASE_ERROR",
214 ApiError::Internal(_) => "INTERNAL_ERROR",
215 ApiError::BadGateway(_) => "BAD_GATEWAY",
216 ApiError::RegistryNotFound => "REGISTRY_NOT_FOUND",
217 ApiError::RegistryUnreachable => "REGISTRY_UNREACHABLE",
218 ApiError::InvalidInput(_) => "INVALID_INPUT",
219 }
220 }
221
222 fn status(&self) -> StatusCode {
224 match self {
225 ApiError::RunNotFound(_) => StatusCode::NOT_FOUND,
226 ApiError::StepNotFound(_) => StatusCode::NOT_FOUND,
227 ApiError::WorkflowNotFound(_) => StatusCode::NOT_FOUND,
228 ApiError::SecretNotFound(_) => StatusCode::NOT_FOUND,
229 ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
230 ApiError::Conflict(_) => StatusCode::CONFLICT,
231 ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
232 ApiError::InvalidCredentials => StatusCode::UNAUTHORIZED,
233 ApiError::DuplicateEmail => StatusCode::CONFLICT,
234 ApiError::DuplicateUsername => StatusCode::CONFLICT,
235 ApiError::WeakPassword(_) => StatusCode::BAD_REQUEST,
236 ApiError::ApiKeyNotFound(_) => StatusCode::NOT_FOUND,
237 ApiError::UserNotFound(_) => StatusCode::NOT_FOUND,
238 ApiError::Forbidden => StatusCode::FORBIDDEN,
239 ApiError::InsufficientScope => StatusCode::FORBIDDEN,
240 ApiError::IdempotencyKeyConflict(_) => StatusCode::CONFLICT,
241 ApiError::MonthlyBudgetExceeded(_) => StatusCode::TOO_MANY_REQUESTS,
242 ApiError::ArtifactNotFound(_) => StatusCode::NOT_FOUND,
243 ApiError::ArtifactStorageUnavailable => StatusCode::NOT_IMPLEMENTED,
244 ApiError::ArtifactTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
245 ApiError::ScheduleNotFound(_) => StatusCode::NOT_FOUND,
246 ApiError::DelegationNotFound(_) => StatusCode::NOT_FOUND,
247 ApiError::ProviderAccountNotFound(_) => StatusCode::NOT_FOUND,
248 ApiError::AccountCredentialRejected(_) => StatusCode::UNPROCESSABLE_ENTITY,
249 ApiError::Store(StoreError::Crypto(_)) => StatusCode::NOT_IMPLEMENTED,
250 ApiError::Store(StoreError::DuplicateArtifact { .. }) => StatusCode::CONFLICT,
251 ApiError::Store(StoreError::LeaseLost { .. }) => StatusCode::CONFLICT,
252 ApiError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
253 ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
254 ApiError::BadGateway(_) => StatusCode::BAD_GATEWAY,
255 ApiError::RegistryNotFound => StatusCode::NOT_FOUND,
256 ApiError::RegistryUnreachable => StatusCode::BAD_GATEWAY,
257 ApiError::InvalidInput(_) => StatusCode::UNPROCESSABLE_ENTITY,
258 }
259 }
260
261 fn details(&self) -> Option<Value> {
266 match self {
267 ApiError::IdempotencyKeyConflict(run_id) => Some(json!({ "run_id": run_id })),
268 ApiError::InvalidInput(errors) => Some(json!({ "errors": errors })),
269 _ => None,
270 }
271 }
272}
273
274impl IntoResponse for ApiError {
275 fn into_response(self) -> Response {
276 let status = self.status();
277 let code = self.code().to_string();
278 let details = self.details();
279
280 let message = match &self {
281 ApiError::Store(StoreError::Crypto(_)) => {
282 "secret store not configured (set IRONFLOW_SECRET_KEYS)".to_string()
283 }
284 ApiError::Store(e @ StoreError::LeaseLost { .. }) => e.to_string(),
287 _ => self.to_string(),
288 };
289
290 match &self {
291 ApiError::Store(e @ StoreError::LeaseLost { .. }) => {
294 warn!(error = %e, code = %code, "lease refused")
295 }
296 ApiError::Store(e) => error!(error = %e, code = %code, "store error"),
297 ApiError::Internal(detail) => {
298 error!(detail = %detail, code = %code, "internal error")
299 }
300 _ => {}
301 }
302
303 let envelope = ErrorEnvelope {
304 code,
305 message,
306 details,
307 };
308
309 (status, Json(json!({ "error": envelope }))).into_response()
310 }
311}
312
313#[cfg(test)]
314mod tests {
315 use super::*;
316
317 #[test]
318 fn run_not_found_code() {
319 let err = ApiError::RunNotFound(Uuid::nil());
320 assert_eq!(err.code(), "RUN_NOT_FOUND");
321 }
322
323 #[test]
324 fn run_not_found_status() {
325 let err = ApiError::RunNotFound(Uuid::nil());
326 assert_eq!(err.status(), StatusCode::NOT_FOUND);
327 }
328
329 #[test]
330 fn bad_request_status() {
331 let err = ApiError::BadRequest("invalid field".to_string());
332 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
333 assert_eq!(err.code(), "BAD_REQUEST");
334 }
335
336 #[test]
337 fn conflict_status() {
338 let err = ApiError::Conflict("run is already waiting for a retry".to_string());
339 assert_eq!(err.status(), StatusCode::CONFLICT);
340 assert_eq!(err.code(), "CONFLICT");
341 }
342
343 #[test]
344 fn invalid_input_status_and_details() {
345 let err = ApiError::InvalidInput(vec!["\"answers\" is a required property".to_string()]);
346 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
347 assert_eq!(err.code(), "INVALID_INPUT");
348 assert_eq!(
349 err.details(),
350 Some(json!({ "errors": ["\"answers\" is a required property"] }))
351 );
352 }
353
354 #[test]
355 fn internal_error_status() {
356 let err = ApiError::Internal("something went wrong".to_string());
357 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
358 assert_eq!(err.code(), "INTERNAL_ERROR");
359 }
360
361 #[test]
362 fn error_to_response() {
363 let err = ApiError::BadRequest("invalid input".to_string());
364 let response = err.into_response();
365 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
366 }
367
368 #[test]
369 fn unauthorized_status() {
370 let err = ApiError::Unauthorized;
371 assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
372 assert_eq!(err.code(), "UNAUTHORIZED");
373 }
374
375 #[test]
376 fn invalid_credentials_status() {
377 let err = ApiError::InvalidCredentials;
378 assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
379 assert_eq!(err.code(), "INVALID_CREDENTIALS");
380 }
381
382 #[test]
383 fn duplicate_email_status() {
384 let err = ApiError::DuplicateEmail;
385 assert_eq!(err.status(), StatusCode::CONFLICT);
386 assert_eq!(err.code(), "DUPLICATE_EMAIL");
387 }
388
389 #[test]
390 fn duplicate_username_status() {
391 let err = ApiError::DuplicateUsername;
392 assert_eq!(err.status(), StatusCode::CONFLICT);
393 assert_eq!(err.code(), "DUPLICATE_USERNAME");
394 }
395
396 #[test]
397 fn weak_password_status_carries_the_rule() {
398 let err = ApiError::from(PasswordPolicyError::TooCommon);
399 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
400 assert_eq!(err.code(), "WEAK_PASSWORD");
401 assert_eq!(err.to_string(), PasswordPolicyError::TooCommon.to_string());
402 }
403
404 #[test]
405 fn workflow_not_found_status() {
406 let err = ApiError::WorkflowNotFound("test".to_string());
407 assert_eq!(err.status(), StatusCode::NOT_FOUND);
408 assert_eq!(err.code(), "WORKFLOW_NOT_FOUND");
409 }
410
411 #[test]
412 fn step_not_found_status() {
413 let err = ApiError::StepNotFound(Uuid::nil());
414 assert_eq!(err.status(), StatusCode::NOT_FOUND);
415 assert_eq!(err.code(), "STEP_NOT_FOUND");
416 }
417
418 #[test]
419 fn user_not_found_status() {
420 let err = ApiError::UserNotFound(Uuid::nil());
421 assert_eq!(err.status(), StatusCode::NOT_FOUND);
422 assert_eq!(err.code(), "USER_NOT_FOUND");
423 }
424
425 #[test]
426 fn forbidden_status() {
427 let err = ApiError::Forbidden;
428 assert_eq!(err.status(), StatusCode::FORBIDDEN);
429 assert_eq!(err.code(), "FORBIDDEN");
430 }
431
432 #[test]
433 fn secret_not_found_status() {
434 let err = ApiError::SecretNotFound("demo/api-key".to_string());
435 assert_eq!(err.status(), StatusCode::NOT_FOUND);
436 assert_eq!(err.code(), "SECRET_NOT_FOUND");
437 }
438
439 #[test]
440 fn delegation_not_found_status_and_code() {
441 let err = ApiError::DelegationNotFound(Uuid::nil());
442 assert_eq!(err.status(), StatusCode::NOT_FOUND);
443 assert_eq!(err.code(), "DELEGATION_NOT_FOUND");
444 }
445
446 #[test]
447 fn provider_account_errors_status_and_code() {
448 let err = ApiError::ProviderAccountNotFound("perso".to_string());
449 assert_eq!(err.status(), StatusCode::NOT_FOUND);
450 assert_eq!(err.code(), "PROVIDER_ACCOUNT_NOT_FOUND");
451
452 let err = ApiError::AccountCredentialRejected("token rejected".to_string());
453 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
454 assert_eq!(err.code(), "ACCOUNT_CREDENTIAL_REJECTED");
455 assert_eq!(err.to_string(), "token rejected");
456 }
457
458 #[test]
459 fn provider_account_store_errors_map_to_http() {
460 let id = Uuid::now_v7();
461 let err = ApiError::from(StoreError::ProviderAccountNotFound(id));
462 assert_eq!(err.status(), StatusCode::NOT_FOUND);
463 assert!(err.to_string().contains(&id.to_string()));
464
465 let err = ApiError::from(StoreError::DuplicateProviderAccount("perso".to_string()));
466 assert_eq!(err.status(), StatusCode::CONFLICT);
467 assert!(err.to_string().contains("perso"));
468 }
469
470 #[test]
471 fn monthly_budget_exceeded_status_and_code() {
472 let err = ApiError::MonthlyBudgetExceeded("quota exhausted".to_string());
473 assert_eq!(err.status(), StatusCode::TOO_MANY_REQUESTS);
474 assert_eq!(err.code(), "MONTHLY_BUDGET_EXCEEDED");
475 assert_eq!(err.to_string(), "quota exhausted");
476 }
477}