1use axum::Json;
8use axum::http::StatusCode;
9use axum::response::{IntoResponse, Response};
10use ironflow_engine::error::MONTHLY_BUDGET_EXCEEDED_CODE;
11use ironflow_store::error::StoreError;
12use ironflow_types::ErrorEnvelope;
13use serde_json::{Value, json};
14use thiserror::Error;
15use tracing::{error, warn};
16use uuid::Uuid;
17
18#[derive(Debug, Error)]
32pub enum ApiError {
33 #[error("run not found")]
35 RunNotFound(Uuid),
36
37 #[error("step not found")]
39 StepNotFound(Uuid),
40
41 #[error("workflow not found")]
43 WorkflowNotFound(String),
44
45 #[error("{0}")]
47 BadRequest(String),
48
49 #[error("{0}")]
51 Conflict(String),
52
53 #[error("authentication required")]
55 Unauthorized,
56
57 #[error("invalid credentials")]
59 InvalidCredentials,
60
61 #[error("email already exists")]
63 DuplicateEmail,
64
65 #[error("username already exists")]
67 DuplicateUsername,
68
69 #[error("API key not found")]
71 ApiKeyNotFound(Uuid),
72
73 #[error("user not found")]
75 UserNotFound(Uuid),
76
77 #[error("insufficient permissions")]
79 Forbidden,
80
81 #[error("secret not found")]
83 SecretNotFound(String),
84
85 #[error("insufficient scope")]
87 InsufficientScope,
88
89 #[error("idempotency key already used with a different request")]
93 IdempotencyKeyConflict(Uuid),
94 #[error("{0}")]
98 MonthlyBudgetExceeded(String),
99
100 #[error("artifact not found")]
102 ArtifactNotFound(String),
103
104 #[error("artifact storage is not configured")]
109 ArtifactStorageUnavailable,
110
111 #[error("artifact exceeds the size limit")]
113 ArtifactTooLarge,
114
115 #[error("schedule not found")]
117 ScheduleNotFound(Uuid),
118
119 #[error("approval delegation not found")]
121 DelegationNotFound(Uuid),
122
123 #[error("provider account not found: {0}")]
125 ProviderAccountNotFound(String),
126
127 #[error("{0}")]
129 AccountCredentialRejected(String),
130
131 #[error("database error")]
133 Store(StoreError),
134
135 #[error("internal server error")]
137 Internal(String),
138
139 #[error("upstream service unavailable")]
141 BadGateway(String),
142
143 #[error("template registry not found, check IRONFLOW_REGISTRY_URL")]
145 RegistryNotFound,
146
147 #[error("template registry unreachable")]
149 RegistryUnreachable,
150
151 #[error("input does not match the expected schema")]
156 InvalidInput(Vec<String>),
157}
158
159impl From<StoreError> for ApiError {
160 fn from(e: StoreError) -> Self {
161 match e {
162 StoreError::ScheduleNotFound(id) => ApiError::ScheduleNotFound(id),
163 StoreError::DelegationNotFound(id) => ApiError::DelegationNotFound(id),
164 StoreError::ProviderAccountNotFound(id) => {
165 ApiError::ProviderAccountNotFound(id.to_string())
166 }
167 StoreError::DuplicateProviderAccount(name) => {
168 ApiError::Conflict(format!("provider account '{name}' already exists"))
169 }
170 other => ApiError::Store(other),
171 }
172 }
173}
174
175impl ApiError {
176 fn code(&self) -> &str {
178 match self {
179 ApiError::RunNotFound(_) => "RUN_NOT_FOUND",
180 ApiError::StepNotFound(_) => "STEP_NOT_FOUND",
181 ApiError::WorkflowNotFound(_) => "WORKFLOW_NOT_FOUND",
182 ApiError::BadRequest(_) => "BAD_REQUEST",
183 ApiError::Conflict(_) => "CONFLICT",
184 ApiError::Unauthorized => "UNAUTHORIZED",
185 ApiError::InvalidCredentials => "INVALID_CREDENTIALS",
186 ApiError::DuplicateEmail => "DUPLICATE_EMAIL",
187 ApiError::DuplicateUsername => "DUPLICATE_USERNAME",
188 ApiError::ApiKeyNotFound(_) => "API_KEY_NOT_FOUND",
189 ApiError::UserNotFound(_) => "USER_NOT_FOUND",
190 ApiError::SecretNotFound(_) => "SECRET_NOT_FOUND",
191 ApiError::Forbidden => "FORBIDDEN",
192 ApiError::InsufficientScope => "INSUFFICIENT_SCOPE",
193 ApiError::IdempotencyKeyConflict(_) => "IDEMPOTENCY_KEY_CONFLICT",
194 ApiError::MonthlyBudgetExceeded(_) => MONTHLY_BUDGET_EXCEEDED_CODE,
195 ApiError::ArtifactNotFound(_) => "ARTIFACT_NOT_FOUND",
196 ApiError::ArtifactStorageUnavailable => "ARTIFACT_STORAGE_UNAVAILABLE",
197 ApiError::ArtifactTooLarge => "ARTIFACT_TOO_LARGE",
198 ApiError::ScheduleNotFound(_) => "SCHEDULE_NOT_FOUND",
199 ApiError::DelegationNotFound(_) => "DELEGATION_NOT_FOUND",
200 ApiError::ProviderAccountNotFound(_) => "PROVIDER_ACCOUNT_NOT_FOUND",
201 ApiError::AccountCredentialRejected(_) => "ACCOUNT_CREDENTIAL_REJECTED",
202 ApiError::Store(StoreError::Crypto(_)) => "SECRET_STORE_UNAVAILABLE",
203 ApiError::Store(StoreError::DuplicateArtifact { .. }) => "DUPLICATE_ARTIFACT",
204 ApiError::Store(StoreError::LeaseLost { .. }) => "LEASE_LOST",
205 ApiError::Store(_) => "DATABASE_ERROR",
206 ApiError::Internal(_) => "INTERNAL_ERROR",
207 ApiError::BadGateway(_) => "BAD_GATEWAY",
208 ApiError::RegistryNotFound => "REGISTRY_NOT_FOUND",
209 ApiError::RegistryUnreachable => "REGISTRY_UNREACHABLE",
210 ApiError::InvalidInput(_) => "INVALID_INPUT",
211 }
212 }
213
214 fn status(&self) -> StatusCode {
216 match self {
217 ApiError::RunNotFound(_) => StatusCode::NOT_FOUND,
218 ApiError::StepNotFound(_) => StatusCode::NOT_FOUND,
219 ApiError::WorkflowNotFound(_) => StatusCode::NOT_FOUND,
220 ApiError::SecretNotFound(_) => StatusCode::NOT_FOUND,
221 ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
222 ApiError::Conflict(_) => StatusCode::CONFLICT,
223 ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
224 ApiError::InvalidCredentials => StatusCode::UNAUTHORIZED,
225 ApiError::DuplicateEmail => StatusCode::CONFLICT,
226 ApiError::DuplicateUsername => StatusCode::CONFLICT,
227 ApiError::ApiKeyNotFound(_) => StatusCode::NOT_FOUND,
228 ApiError::UserNotFound(_) => StatusCode::NOT_FOUND,
229 ApiError::Forbidden => StatusCode::FORBIDDEN,
230 ApiError::InsufficientScope => StatusCode::FORBIDDEN,
231 ApiError::IdempotencyKeyConflict(_) => StatusCode::CONFLICT,
232 ApiError::MonthlyBudgetExceeded(_) => StatusCode::TOO_MANY_REQUESTS,
233 ApiError::ArtifactNotFound(_) => StatusCode::NOT_FOUND,
234 ApiError::ArtifactStorageUnavailable => StatusCode::NOT_IMPLEMENTED,
235 ApiError::ArtifactTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
236 ApiError::ScheduleNotFound(_) => StatusCode::NOT_FOUND,
237 ApiError::DelegationNotFound(_) => StatusCode::NOT_FOUND,
238 ApiError::ProviderAccountNotFound(_) => StatusCode::NOT_FOUND,
239 ApiError::AccountCredentialRejected(_) => StatusCode::UNPROCESSABLE_ENTITY,
240 ApiError::Store(StoreError::Crypto(_)) => StatusCode::NOT_IMPLEMENTED,
241 ApiError::Store(StoreError::DuplicateArtifact { .. }) => StatusCode::CONFLICT,
242 ApiError::Store(StoreError::LeaseLost { .. }) => StatusCode::CONFLICT,
243 ApiError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
244 ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
245 ApiError::BadGateway(_) => StatusCode::BAD_GATEWAY,
246 ApiError::RegistryNotFound => StatusCode::NOT_FOUND,
247 ApiError::RegistryUnreachable => StatusCode::BAD_GATEWAY,
248 ApiError::InvalidInput(_) => StatusCode::UNPROCESSABLE_ENTITY,
249 }
250 }
251
252 fn details(&self) -> Option<Value> {
257 match self {
258 ApiError::IdempotencyKeyConflict(run_id) => Some(json!({ "run_id": run_id })),
259 ApiError::InvalidInput(errors) => Some(json!({ "errors": errors })),
260 _ => None,
261 }
262 }
263}
264
265impl IntoResponse for ApiError {
266 fn into_response(self) -> Response {
267 let status = self.status();
268 let code = self.code().to_string();
269 let details = self.details();
270
271 let message = match &self {
272 ApiError::Store(StoreError::Crypto(_)) => {
273 "secret store not configured (set IRONFLOW_SECRET_KEYS)".to_string()
274 }
275 ApiError::Store(e @ StoreError::LeaseLost { .. }) => e.to_string(),
278 _ => self.to_string(),
279 };
280
281 match &self {
282 ApiError::Store(e @ StoreError::LeaseLost { .. }) => {
285 warn!(error = %e, code = %code, "lease refused")
286 }
287 ApiError::Store(e) => error!(error = %e, code = %code, "store error"),
288 ApiError::Internal(detail) => {
289 error!(detail = %detail, code = %code, "internal error")
290 }
291 _ => {}
292 }
293
294 let envelope = ErrorEnvelope {
295 code,
296 message,
297 details,
298 };
299
300 (status, Json(json!({ "error": envelope }))).into_response()
301 }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn run_not_found_code() {
310 let err = ApiError::RunNotFound(Uuid::nil());
311 assert_eq!(err.code(), "RUN_NOT_FOUND");
312 }
313
314 #[test]
315 fn run_not_found_status() {
316 let err = ApiError::RunNotFound(Uuid::nil());
317 assert_eq!(err.status(), StatusCode::NOT_FOUND);
318 }
319
320 #[test]
321 fn bad_request_status() {
322 let err = ApiError::BadRequest("invalid field".to_string());
323 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
324 assert_eq!(err.code(), "BAD_REQUEST");
325 }
326
327 #[test]
328 fn conflict_status() {
329 let err = ApiError::Conflict("run is already waiting for a retry".to_string());
330 assert_eq!(err.status(), StatusCode::CONFLICT);
331 assert_eq!(err.code(), "CONFLICT");
332 }
333
334 #[test]
335 fn invalid_input_status_and_details() {
336 let err = ApiError::InvalidInput(vec!["\"answers\" is a required property".to_string()]);
337 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
338 assert_eq!(err.code(), "INVALID_INPUT");
339 assert_eq!(
340 err.details(),
341 Some(json!({ "errors": ["\"answers\" is a required property"] }))
342 );
343 }
344
345 #[test]
346 fn internal_error_status() {
347 let err = ApiError::Internal("something went wrong".to_string());
348 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
349 assert_eq!(err.code(), "INTERNAL_ERROR");
350 }
351
352 #[test]
353 fn error_to_response() {
354 let err = ApiError::BadRequest("invalid input".to_string());
355 let response = err.into_response();
356 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
357 }
358
359 #[test]
360 fn unauthorized_status() {
361 let err = ApiError::Unauthorized;
362 assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
363 assert_eq!(err.code(), "UNAUTHORIZED");
364 }
365
366 #[test]
367 fn invalid_credentials_status() {
368 let err = ApiError::InvalidCredentials;
369 assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
370 assert_eq!(err.code(), "INVALID_CREDENTIALS");
371 }
372
373 #[test]
374 fn duplicate_email_status() {
375 let err = ApiError::DuplicateEmail;
376 assert_eq!(err.status(), StatusCode::CONFLICT);
377 assert_eq!(err.code(), "DUPLICATE_EMAIL");
378 }
379
380 #[test]
381 fn duplicate_username_status() {
382 let err = ApiError::DuplicateUsername;
383 assert_eq!(err.status(), StatusCode::CONFLICT);
384 assert_eq!(err.code(), "DUPLICATE_USERNAME");
385 }
386
387 #[test]
388 fn workflow_not_found_status() {
389 let err = ApiError::WorkflowNotFound("test".to_string());
390 assert_eq!(err.status(), StatusCode::NOT_FOUND);
391 assert_eq!(err.code(), "WORKFLOW_NOT_FOUND");
392 }
393
394 #[test]
395 fn step_not_found_status() {
396 let err = ApiError::StepNotFound(Uuid::nil());
397 assert_eq!(err.status(), StatusCode::NOT_FOUND);
398 assert_eq!(err.code(), "STEP_NOT_FOUND");
399 }
400
401 #[test]
402 fn user_not_found_status() {
403 let err = ApiError::UserNotFound(Uuid::nil());
404 assert_eq!(err.status(), StatusCode::NOT_FOUND);
405 assert_eq!(err.code(), "USER_NOT_FOUND");
406 }
407
408 #[test]
409 fn forbidden_status() {
410 let err = ApiError::Forbidden;
411 assert_eq!(err.status(), StatusCode::FORBIDDEN);
412 assert_eq!(err.code(), "FORBIDDEN");
413 }
414
415 #[test]
416 fn secret_not_found_status() {
417 let err = ApiError::SecretNotFound("demo/api-key".to_string());
418 assert_eq!(err.status(), StatusCode::NOT_FOUND);
419 assert_eq!(err.code(), "SECRET_NOT_FOUND");
420 }
421
422 #[test]
423 fn delegation_not_found_status_and_code() {
424 let err = ApiError::DelegationNotFound(Uuid::nil());
425 assert_eq!(err.status(), StatusCode::NOT_FOUND);
426 assert_eq!(err.code(), "DELEGATION_NOT_FOUND");
427 }
428
429 #[test]
430 fn provider_account_errors_status_and_code() {
431 let err = ApiError::ProviderAccountNotFound("perso".to_string());
432 assert_eq!(err.status(), StatusCode::NOT_FOUND);
433 assert_eq!(err.code(), "PROVIDER_ACCOUNT_NOT_FOUND");
434
435 let err = ApiError::AccountCredentialRejected("token rejected".to_string());
436 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
437 assert_eq!(err.code(), "ACCOUNT_CREDENTIAL_REJECTED");
438 assert_eq!(err.to_string(), "token rejected");
439 }
440
441 #[test]
442 fn provider_account_store_errors_map_to_http() {
443 let id = Uuid::now_v7();
444 let err = ApiError::from(StoreError::ProviderAccountNotFound(id));
445 assert_eq!(err.status(), StatusCode::NOT_FOUND);
446 assert!(err.to_string().contains(&id.to_string()));
447
448 let err = ApiError::from(StoreError::DuplicateProviderAccount("perso".to_string()));
449 assert_eq!(err.status(), StatusCode::CONFLICT);
450 assert!(err.to_string().contains("perso"));
451 }
452
453 #[test]
454 fn monthly_budget_exceeded_status_and_code() {
455 let err = ApiError::MonthlyBudgetExceeded("quota exhausted".to_string());
456 assert_eq!(err.status(), StatusCode::TOO_MANY_REQUESTS);
457 assert_eq!(err.code(), "MONTHLY_BUDGET_EXCEEDED");
458 assert_eq!(err.to_string(), "quota exhausted");
459 }
460}