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("database error")]
121 Store(StoreError),
122
123 #[error("internal server error")]
125 Internal(String),
126}
127
128impl From<StoreError> for ApiError {
129 fn from(e: StoreError) -> Self {
130 match e {
131 StoreError::ScheduleNotFound(id) => ApiError::ScheduleNotFound(id),
132 other => ApiError::Store(other),
133 }
134 }
135}
136
137impl ApiError {
138 fn code(&self) -> &str {
140 match self {
141 ApiError::RunNotFound(_) => "RUN_NOT_FOUND",
142 ApiError::StepNotFound(_) => "STEP_NOT_FOUND",
143 ApiError::WorkflowNotFound(_) => "WORKFLOW_NOT_FOUND",
144 ApiError::BadRequest(_) => "BAD_REQUEST",
145 ApiError::Conflict(_) => "CONFLICT",
146 ApiError::Unauthorized => "UNAUTHORIZED",
147 ApiError::InvalidCredentials => "INVALID_CREDENTIALS",
148 ApiError::DuplicateEmail => "DUPLICATE_EMAIL",
149 ApiError::DuplicateUsername => "DUPLICATE_USERNAME",
150 ApiError::ApiKeyNotFound(_) => "API_KEY_NOT_FOUND",
151 ApiError::UserNotFound(_) => "USER_NOT_FOUND",
152 ApiError::SecretNotFound(_) => "SECRET_NOT_FOUND",
153 ApiError::Forbidden => "FORBIDDEN",
154 ApiError::InsufficientScope => "INSUFFICIENT_SCOPE",
155 ApiError::IdempotencyKeyConflict(_) => "IDEMPOTENCY_KEY_CONFLICT",
156 ApiError::MonthlyBudgetExceeded(_) => MONTHLY_BUDGET_EXCEEDED_CODE,
157 ApiError::ArtifactNotFound(_) => "ARTIFACT_NOT_FOUND",
158 ApiError::ArtifactStorageUnavailable => "ARTIFACT_STORAGE_UNAVAILABLE",
159 ApiError::ArtifactTooLarge => "ARTIFACT_TOO_LARGE",
160 ApiError::ScheduleNotFound(_) => "SCHEDULE_NOT_FOUND",
161 ApiError::Store(StoreError::Crypto(_)) => "SECRET_STORE_UNAVAILABLE",
162 ApiError::Store(StoreError::DuplicateArtifact { .. }) => "DUPLICATE_ARTIFACT",
163 ApiError::Store(StoreError::LeaseLost { .. }) => "LEASE_LOST",
164 ApiError::Store(_) => "DATABASE_ERROR",
165 ApiError::Internal(_) => "INTERNAL_ERROR",
166 }
167 }
168
169 fn status(&self) -> StatusCode {
171 match self {
172 ApiError::RunNotFound(_) => StatusCode::NOT_FOUND,
173 ApiError::StepNotFound(_) => StatusCode::NOT_FOUND,
174 ApiError::WorkflowNotFound(_) => StatusCode::NOT_FOUND,
175 ApiError::SecretNotFound(_) => StatusCode::NOT_FOUND,
176 ApiError::BadRequest(_) => StatusCode::BAD_REQUEST,
177 ApiError::Conflict(_) => StatusCode::CONFLICT,
178 ApiError::Unauthorized => StatusCode::UNAUTHORIZED,
179 ApiError::InvalidCredentials => StatusCode::UNAUTHORIZED,
180 ApiError::DuplicateEmail => StatusCode::CONFLICT,
181 ApiError::DuplicateUsername => StatusCode::CONFLICT,
182 ApiError::ApiKeyNotFound(_) => StatusCode::NOT_FOUND,
183 ApiError::UserNotFound(_) => StatusCode::NOT_FOUND,
184 ApiError::Forbidden => StatusCode::FORBIDDEN,
185 ApiError::InsufficientScope => StatusCode::FORBIDDEN,
186 ApiError::IdempotencyKeyConflict(_) => StatusCode::CONFLICT,
187 ApiError::MonthlyBudgetExceeded(_) => StatusCode::TOO_MANY_REQUESTS,
188 ApiError::ArtifactNotFound(_) => StatusCode::NOT_FOUND,
189 ApiError::ArtifactStorageUnavailable => StatusCode::NOT_IMPLEMENTED,
190 ApiError::ArtifactTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
191 ApiError::ScheduleNotFound(_) => StatusCode::NOT_FOUND,
192 ApiError::Store(StoreError::Crypto(_)) => StatusCode::NOT_IMPLEMENTED,
193 ApiError::Store(StoreError::DuplicateArtifact { .. }) => StatusCode::CONFLICT,
194 ApiError::Store(StoreError::LeaseLost { .. }) => StatusCode::CONFLICT,
195 ApiError::Store(_) => StatusCode::INTERNAL_SERVER_ERROR,
196 ApiError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
197 }
198 }
199
200 fn details(&self) -> Option<Value> {
205 match self {
206 ApiError::IdempotencyKeyConflict(run_id) => Some(json!({ "run_id": run_id })),
207 _ => None,
208 }
209 }
210}
211
212impl IntoResponse for ApiError {
213 fn into_response(self) -> Response {
214 let status = self.status();
215 let code = self.code().to_string();
216 let details = self.details();
217
218 let message = match &self {
219 ApiError::Store(StoreError::Crypto(_)) => {
220 "secret store not configured (set IRONFLOW_SECRET_KEYS)".to_string()
221 }
222 ApiError::Store(e @ StoreError::LeaseLost { .. }) => e.to_string(),
225 _ => self.to_string(),
226 };
227
228 match &self {
229 ApiError::Store(e @ StoreError::LeaseLost { .. }) => {
232 warn!(error = %e, code = %code, "lease refused")
233 }
234 ApiError::Store(e) => error!(error = %e, code = %code, "store error"),
235 ApiError::Internal(detail) => {
236 error!(detail = %detail, code = %code, "internal error")
237 }
238 _ => {}
239 }
240
241 let envelope = ErrorEnvelope {
242 code,
243 message,
244 details,
245 };
246
247 (status, Json(json!({ "error": envelope }))).into_response()
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn run_not_found_code() {
257 let err = ApiError::RunNotFound(Uuid::nil());
258 assert_eq!(err.code(), "RUN_NOT_FOUND");
259 }
260
261 #[test]
262 fn run_not_found_status() {
263 let err = ApiError::RunNotFound(Uuid::nil());
264 assert_eq!(err.status(), StatusCode::NOT_FOUND);
265 }
266
267 #[test]
268 fn bad_request_status() {
269 let err = ApiError::BadRequest("invalid field".to_string());
270 assert_eq!(err.status(), StatusCode::BAD_REQUEST);
271 assert_eq!(err.code(), "BAD_REQUEST");
272 }
273
274 #[test]
275 fn conflict_status() {
276 let err = ApiError::Conflict("run is already waiting for a retry".to_string());
277 assert_eq!(err.status(), StatusCode::CONFLICT);
278 assert_eq!(err.code(), "CONFLICT");
279 }
280
281 #[test]
282 fn internal_error_status() {
283 let err = ApiError::Internal("something went wrong".to_string());
284 assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
285 assert_eq!(err.code(), "INTERNAL_ERROR");
286 }
287
288 #[test]
289 fn error_to_response() {
290 let err = ApiError::BadRequest("invalid input".to_string());
291 let response = err.into_response();
292 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
293 }
294
295 #[test]
296 fn unauthorized_status() {
297 let err = ApiError::Unauthorized;
298 assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
299 assert_eq!(err.code(), "UNAUTHORIZED");
300 }
301
302 #[test]
303 fn invalid_credentials_status() {
304 let err = ApiError::InvalidCredentials;
305 assert_eq!(err.status(), StatusCode::UNAUTHORIZED);
306 assert_eq!(err.code(), "INVALID_CREDENTIALS");
307 }
308
309 #[test]
310 fn duplicate_email_status() {
311 let err = ApiError::DuplicateEmail;
312 assert_eq!(err.status(), StatusCode::CONFLICT);
313 assert_eq!(err.code(), "DUPLICATE_EMAIL");
314 }
315
316 #[test]
317 fn duplicate_username_status() {
318 let err = ApiError::DuplicateUsername;
319 assert_eq!(err.status(), StatusCode::CONFLICT);
320 assert_eq!(err.code(), "DUPLICATE_USERNAME");
321 }
322
323 #[test]
324 fn workflow_not_found_status() {
325 let err = ApiError::WorkflowNotFound("test".to_string());
326 assert_eq!(err.status(), StatusCode::NOT_FOUND);
327 assert_eq!(err.code(), "WORKFLOW_NOT_FOUND");
328 }
329
330 #[test]
331 fn step_not_found_status() {
332 let err = ApiError::StepNotFound(Uuid::nil());
333 assert_eq!(err.status(), StatusCode::NOT_FOUND);
334 assert_eq!(err.code(), "STEP_NOT_FOUND");
335 }
336
337 #[test]
338 fn user_not_found_status() {
339 let err = ApiError::UserNotFound(Uuid::nil());
340 assert_eq!(err.status(), StatusCode::NOT_FOUND);
341 assert_eq!(err.code(), "USER_NOT_FOUND");
342 }
343
344 #[test]
345 fn forbidden_status() {
346 let err = ApiError::Forbidden;
347 assert_eq!(err.status(), StatusCode::FORBIDDEN);
348 assert_eq!(err.code(), "FORBIDDEN");
349 }
350
351 #[test]
352 fn secret_not_found_status() {
353 let err = ApiError::SecretNotFound("demo/api-key".to_string());
354 assert_eq!(err.status(), StatusCode::NOT_FOUND);
355 assert_eq!(err.code(), "SECRET_NOT_FOUND");
356 }
357
358 #[test]
359 fn monthly_budget_exceeded_status_and_code() {
360 let err = ApiError::MonthlyBudgetExceeded("quota exhausted".to_string());
361 assert_eq!(err.status(), StatusCode::TOO_MANY_REQUESTS);
362 assert_eq!(err.code(), "MONTHLY_BUDGET_EXCEEDED");
363 assert_eq!(err.to_string(), "quota exhausted");
364 }
365}