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("database error")]
125 Store(StoreError),
126
127 #[error("internal server error")]
129 Internal(String),
130
131 #[error("upstream service unavailable")]
133 BadGateway(String),
134
135 #[error("template registry not found, check IRONFLOW_REGISTRY_URL")]
137 RegistryNotFound,
138
139 #[error("template registry unreachable")]
141 RegistryUnreachable,
142
143 #[error("input does not match the expected schema")]
148 InvalidInput(Vec<String>),
149}
150
151impl From<StoreError> for ApiError {
152 fn from(e: StoreError) -> Self {
153 match e {
154 StoreError::ScheduleNotFound(id) => ApiError::ScheduleNotFound(id),
155 StoreError::DelegationNotFound(id) => ApiError::DelegationNotFound(id),
156 other => ApiError::Store(other),
157 }
158 }
159}
160
161impl ApiError {
162 fn code(&self) -> &str {
164 match self {
165 ApiError::RunNotFound(_) => "RUN_NOT_FOUND",
166 ApiError::StepNotFound(_) => "STEP_NOT_FOUND",
167 ApiError::WorkflowNotFound(_) => "WORKFLOW_NOT_FOUND",
168 ApiError::BadRequest(_) => "BAD_REQUEST",
169 ApiError::Conflict(_) => "CONFLICT",
170 ApiError::Unauthorized => "UNAUTHORIZED",
171 ApiError::InvalidCredentials => "INVALID_CREDENTIALS",
172 ApiError::DuplicateEmail => "DUPLICATE_EMAIL",
173 ApiError::DuplicateUsername => "DUPLICATE_USERNAME",
174 ApiError::ApiKeyNotFound(_) => "API_KEY_NOT_FOUND",
175 ApiError::UserNotFound(_) => "USER_NOT_FOUND",
176 ApiError::SecretNotFound(_) => "SECRET_NOT_FOUND",
177 ApiError::Forbidden => "FORBIDDEN",
178 ApiError::InsufficientScope => "INSUFFICIENT_SCOPE",
179 ApiError::IdempotencyKeyConflict(_) => "IDEMPOTENCY_KEY_CONFLICT",
180 ApiError::MonthlyBudgetExceeded(_) => MONTHLY_BUDGET_EXCEEDED_CODE,
181 ApiError::ArtifactNotFound(_) => "ARTIFACT_NOT_FOUND",
182 ApiError::ArtifactStorageUnavailable => "ARTIFACT_STORAGE_UNAVAILABLE",
183 ApiError::ArtifactTooLarge => "ARTIFACT_TOO_LARGE",
184 ApiError::ScheduleNotFound(_) => "SCHEDULE_NOT_FOUND",
185 ApiError::DelegationNotFound(_) => "DELEGATION_NOT_FOUND",
186 ApiError::Store(StoreError::Crypto(_)) => "SECRET_STORE_UNAVAILABLE",
187 ApiError::Store(StoreError::DuplicateArtifact { .. }) => "DUPLICATE_ARTIFACT",
188 ApiError::Store(StoreError::LeaseLost { .. }) => "LEASE_LOST",
189 ApiError::Store(_) => "DATABASE_ERROR",
190 ApiError::Internal(_) => "INTERNAL_ERROR",
191 ApiError::BadGateway(_) => "BAD_GATEWAY",
192 ApiError::RegistryNotFound => "REGISTRY_NOT_FOUND",
193 ApiError::RegistryUnreachable => "REGISTRY_UNREACHABLE",
194 ApiError::InvalidInput(_) => "INVALID_INPUT",
195 }
196 }
197
198 fn status(&self) -> StatusCode {
200 match self {
201 ApiError::RunNotFound(_) => StatusCode::NOT_FOUND,
202 ApiError::StepNotFound(_) => StatusCode::NOT_FOUND,
203 ApiError::WorkflowNotFound(_) => StatusCode::NOT_FOUND,
204 ApiError::SecretNotFound(_) => StatusCode::NOT_FOUND,
205 ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
206 ApiError::Conflict(_) => StatusCode::CONFLICT,
207 ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
208 ApiError::InvalidCredentials => StatusCode::UNAUTHORIZED,
209 ApiError::DuplicateEmail => StatusCode::CONFLICT,
210 ApiError::DuplicateUsername => StatusCode::CONFLICT,
211 ApiError::ApiKeyNotFound(_) => StatusCode::NOT_FOUND,
212 ApiError::UserNotFound(_) => StatusCode::NOT_FOUND,
213 ApiError::Forbidden => StatusCode::FORBIDDEN,
214 ApiError::InsufficientScope => StatusCode::FORBIDDEN,
215 ApiError::IdempotencyKeyConflict(_) => StatusCode::CONFLICT,
216 ApiError::MonthlyBudgetExceeded(_) => StatusCode::TOO_MANY_REQUESTS,
217 ApiError::ArtifactNotFound(_) => StatusCode::NOT_FOUND,
218 ApiError::ArtifactStorageUnavailable => StatusCode::NOT_IMPLEMENTED,
219 ApiError::ArtifactTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
220 ApiError::ScheduleNotFound(_) => StatusCode::NOT_FOUND,
221 ApiError::DelegationNotFound(_) => StatusCode::NOT_FOUND,
222 ApiError::Store(StoreError::Crypto(_)) => StatusCode::NOT_IMPLEMENTED,
223 ApiError::Store(StoreError::DuplicateArtifact { .. }) => StatusCode::CONFLICT,
224 ApiError::Store(StoreError::LeaseLost { .. }) => StatusCode::CONFLICT,
225 ApiError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
226 ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
227 ApiError::BadGateway(_) => StatusCode::BAD_GATEWAY,
228 ApiError::RegistryNotFound => StatusCode::NOT_FOUND,
229 ApiError::RegistryUnreachable => StatusCode::BAD_GATEWAY,
230 ApiError::InvalidInput(_) => StatusCode::UNPROCESSABLE_ENTITY,
231 }
232 }
233
234 fn details(&self) -> Option<Value> {
239 match self {
240 ApiError::IdempotencyKeyConflict(run_id) => Some(json!({ "run_id": run_id })),
241 ApiError::InvalidInput(errors) => Some(json!({ "errors": errors })),
242 _ => None,
243 }
244 }
245}
246
247impl IntoResponse for ApiError {
248 fn into_response(self) -> Response {
249 let status = self.status();
250 let code = self.code().to_string();
251 let details = self.details();
252
253 let message = match &self {
254 ApiError::Store(StoreError::Crypto(_)) => {
255 "secret store not configured (set IRONFLOW_SECRET_KEYS)".to_string()
256 }
257 ApiError::Store(e @ StoreError::LeaseLost { .. }) => e.to_string(),
260 _ => self.to_string(),
261 };
262
263 match &self {
264 ApiError::Store(e @ StoreError::LeaseLost { .. }) => {
267 warn!(error = %e, code = %code, "lease refused")
268 }
269 ApiError::Store(e) => error!(error = %e, code = %code, "store error"),
270 ApiError::Internal(detail) => {
271 error!(detail = %detail, code = %code, "internal error")
272 }
273 _ => {}
274 }
275
276 let envelope = ErrorEnvelope {
277 code,
278 message,
279 details,
280 };
281
282 (status, Json(json!({ "error": envelope }))).into_response()
283 }
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289
290 #[test]
291 fn run_not_found_code() {
292 let err = ApiError::RunNotFound(Uuid::nil());
293 assert_eq!(err.code(), "RUN_NOT_FOUND");
294 }
295
296 #[test]
297 fn run_not_found_status() {
298 let err = ApiError::RunNotFound(Uuid::nil());
299 assert_eq!(err.status(), StatusCode::NOT_FOUND);
300 }
301
302 #[test]
303 fn bad_request_status() {
304 let err = ApiError::BadRequest("invalid field".to_string());
305 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
306 assert_eq!(err.code(), "BAD_REQUEST");
307 }
308
309 #[test]
310 fn conflict_status() {
311 let err = ApiError::Conflict("run is already waiting for a retry".to_string());
312 assert_eq!(err.status(), StatusCode::CONFLICT);
313 assert_eq!(err.code(), "CONFLICT");
314 }
315
316 #[test]
317 fn invalid_input_status_and_details() {
318 let err = ApiError::InvalidInput(vec!["\"answers\" is a required property".to_string()]);
319 assert_eq!(err.status(), StatusCode::UNPROCESSABLE_ENTITY);
320 assert_eq!(err.code(), "INVALID_INPUT");
321 assert_eq!(
322 err.details(),
323 Some(json!({ "errors": ["\"answers\" is a required property"] }))
324 );
325 }
326
327 #[test]
328 fn internal_error_status() {
329 let err = ApiError::Internal("something went wrong".to_string());
330 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
331 assert_eq!(err.code(), "INTERNAL_ERROR");
332 }
333
334 #[test]
335 fn error_to_response() {
336 let err = ApiError::BadRequest("invalid input".to_string());
337 let response = err.into_response();
338 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
339 }
340
341 #[test]
342 fn unauthorized_status() {
343 let err = ApiError::Unauthorized;
344 assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
345 assert_eq!(err.code(), "UNAUTHORIZED");
346 }
347
348 #[test]
349 fn invalid_credentials_status() {
350 let err = ApiError::InvalidCredentials;
351 assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
352 assert_eq!(err.code(), "INVALID_CREDENTIALS");
353 }
354
355 #[test]
356 fn duplicate_email_status() {
357 let err = ApiError::DuplicateEmail;
358 assert_eq!(err.status(), StatusCode::CONFLICT);
359 assert_eq!(err.code(), "DUPLICATE_EMAIL");
360 }
361
362 #[test]
363 fn duplicate_username_status() {
364 let err = ApiError::DuplicateUsername;
365 assert_eq!(err.status(), StatusCode::CONFLICT);
366 assert_eq!(err.code(), "DUPLICATE_USERNAME");
367 }
368
369 #[test]
370 fn workflow_not_found_status() {
371 let err = ApiError::WorkflowNotFound("test".to_string());
372 assert_eq!(err.status(), StatusCode::NOT_FOUND);
373 assert_eq!(err.code(), "WORKFLOW_NOT_FOUND");
374 }
375
376 #[test]
377 fn step_not_found_status() {
378 let err = ApiError::StepNotFound(Uuid::nil());
379 assert_eq!(err.status(), StatusCode::NOT_FOUND);
380 assert_eq!(err.code(), "STEP_NOT_FOUND");
381 }
382
383 #[test]
384 fn user_not_found_status() {
385 let err = ApiError::UserNotFound(Uuid::nil());
386 assert_eq!(err.status(), StatusCode::NOT_FOUND);
387 assert_eq!(err.code(), "USER_NOT_FOUND");
388 }
389
390 #[test]
391 fn forbidden_status() {
392 let err = ApiError::Forbidden;
393 assert_eq!(err.status(), StatusCode::FORBIDDEN);
394 assert_eq!(err.code(), "FORBIDDEN");
395 }
396
397 #[test]
398 fn secret_not_found_status() {
399 let err = ApiError::SecretNotFound("demo/api-key".to_string());
400 assert_eq!(err.status(), StatusCode::NOT_FOUND);
401 assert_eq!(err.code(), "SECRET_NOT_FOUND");
402 }
403
404 #[test]
405 fn delegation_not_found_status_and_code() {
406 let err = ApiError::DelegationNotFound(Uuid::nil());
407 assert_eq!(err.status(), StatusCode::NOT_FOUND);
408 assert_eq!(err.code(), "DELEGATION_NOT_FOUND");
409 }
410
411 #[test]
412 fn monthly_budget_exceeded_status_and_code() {
413 let err = ApiError::MonthlyBudgetExceeded("quota exhausted".to_string());
414 assert_eq!(err.status(), StatusCode::TOO_MANY_REQUESTS);
415 assert_eq!(err.code(), "MONTHLY_BUDGET_EXCEEDED");
416 assert_eq!(err.to_string(), "quota exhausted");
417 }
418}