1use axum::Json;
5use axum::http::{HeaderValue, StatusCode, header};
6use axum::response::{IntoResponse, Response};
7use serde::Serialize;
8use utoipa::ToSchema;
9
10#[derive(Debug)]
15pub enum ApiError {
16 NotFound(String),
18 Conflict(String),
21 BadRequest(String),
25 Forbidden(String),
27 TooManyRequests {
29 message: String,
30 retry_after_secs: u64,
31 },
32 GlobalCapacity {
36 message: String,
37 retry_after_secs: u64,
38 },
39 Unauthorized(String),
41 Internal(anyhow::Error),
46}
47
48#[derive(Serialize, ToSchema)]
53pub struct ErrorBody {
54 pub error: String,
55}
56
57impl IntoResponse for ApiError {
58 fn into_response(self) -> Response {
59 let (status, message, retry_after_secs) = match self {
60 ApiError::NotFound(message) => (StatusCode::NOT_FOUND, message, None),
61 ApiError::Conflict(message) => (StatusCode::CONFLICT, message, None),
62 ApiError::BadRequest(message) => (StatusCode::BAD_REQUEST, message, None),
63 ApiError::Forbidden(message) => (StatusCode::FORBIDDEN, message, None),
64 ApiError::TooManyRequests {
65 message,
66 retry_after_secs,
67 } => (
68 StatusCode::TOO_MANY_REQUESTS,
69 message,
70 Some(retry_after_secs),
71 ),
72 ApiError::GlobalCapacity {
73 message,
74 retry_after_secs,
75 } => (
76 StatusCode::SERVICE_UNAVAILABLE,
77 message,
78 Some(retry_after_secs),
79 ),
80 ApiError::Unauthorized(message) => (StatusCode::UNAUTHORIZED, message, None),
81 ApiError::Internal(err) => {
82 tracing::error!(error = %err, "internal error handling request");
83 (
84 StatusCode::INTERNAL_SERVER_ERROR,
85 "internal server error".to_string(),
86 None,
87 )
88 }
89 };
90 let mut response = (status, Json(ErrorBody { error: message })).into_response();
91 if let Some(retry_after_secs) = retry_after_secs {
92 response.headers_mut().insert(
93 header::RETRY_AFTER,
94 HeaderValue::from_str(&retry_after_secs.to_string())
95 .expect("an integer is always a valid Retry-After header value"),
96 );
97 }
98 response
99 }
100}
101
102impl From<bhtune_db::DbError> for ApiError {
103 fn from(err: bhtune_db::DbError) -> Self {
108 match err {
109 bhtune_db::DbError::TemplateInUse { id } => ApiError::Conflict(format!(
110 "template {id} is still referenced by one or more saved loops and cannot be deleted"
111 )),
112 other => ApiError::Internal(other.into()),
113 }
114 }
115}
116
117impl From<anyhow::Error> for ApiError {
118 fn from(err: anyhow::Error) -> Self {
119 ApiError::Internal(err)
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use axum::body::to_bytes;
127
128 async fn body_json(response: Response) -> serde_json::Value {
129 let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
130 serde_json::from_slice(&bytes).unwrap()
131 }
132
133 #[tokio::test]
134 async fn not_found_maps_to_404_with_the_message() {
135 let response = ApiError::NotFound("no template named 'X'".to_string()).into_response();
136 assert_eq!(response.status(), StatusCode::NOT_FOUND);
137 assert_eq!(
138 body_json(response).await,
139 serde_json::json!({"error": "no template named 'X'"})
140 );
141 }
142
143 #[tokio::test]
144 async fn conflict_maps_to_409_with_the_message() {
145 let response = ApiError::Conflict("already exists".to_string()).into_response();
146 assert_eq!(response.status(), StatusCode::CONFLICT);
147 assert_eq!(
148 body_json(response).await,
149 serde_json::json!({"error": "already exists"})
150 );
151 }
152
153 #[tokio::test]
154 async fn bad_request_maps_to_400_with_the_message() {
155 let response = ApiError::BadRequest("invalid".to_string()).into_response();
156 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
157 assert_eq!(
158 body_json(response).await,
159 serde_json::json!({"error": "invalid"})
160 );
161 }
162
163 #[tokio::test]
164 async fn forbidden_maps_to_403_with_the_message() {
165 let response = ApiError::Forbidden("not permitted".to_string()).into_response();
166 assert_eq!(response.status(), StatusCode::FORBIDDEN);
167 assert_eq!(
168 body_json(response).await,
169 serde_json::json!({"error": "not permitted"})
170 );
171 }
172
173 #[tokio::test]
174 async fn too_many_requests_maps_to_429_with_retry_after() {
175 let response = ApiError::TooManyRequests {
176 message: "slow down".to_string(),
177 retry_after_secs: 17,
178 }
179 .into_response();
180 assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
181 assert_eq!(response.headers()[header::RETRY_AFTER], "17");
182 assert_eq!(
183 body_json(response).await,
184 serde_json::json!({"error": "slow down"})
185 );
186 }
187
188 #[tokio::test]
189 async fn global_capacity_maps_to_503_with_retry_after() {
190 let response = ApiError::GlobalCapacity {
191 message: "demo capacity exhausted".to_string(),
192 retry_after_secs: 3,
193 }
194 .into_response();
195 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
196 assert_eq!(response.headers()[header::RETRY_AFTER], "3");
197 assert_eq!(
198 body_json(response).await,
199 serde_json::json!({"error": "demo capacity exhausted"})
200 );
201 }
202
203 #[tokio::test]
204 async fn internal_maps_to_500_and_never_leaks_the_underlying_message() {
205 let response =
206 ApiError::Internal(anyhow::anyhow!("secret db path leaked here")).into_response();
207 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
208 assert_eq!(
209 body_json(response).await,
210 serde_json::json!({"error": "internal server error"})
211 );
212 }
213
214 #[tokio::test]
215 async fn template_in_use_db_error_maps_to_409() {
216 let api_err: ApiError = bhtune_db::DbError::TemplateInUse { id: 7 }.into();
217 assert!(matches!(api_err, ApiError::Conflict(_)));
218 let response = api_err.into_response();
219 assert_eq!(response.status(), StatusCode::CONFLICT);
220 }
221
222 #[tokio::test]
223 async fn other_db_errors_map_to_internal() {
224 let api_err: ApiError = bhtune_db::DbError::InvalidBackup("bad file".to_string()).into();
225 assert!(matches!(api_err, ApiError::Internal(_)));
226 let response = api_err.into_response();
227 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
228 }
229
230 #[test]
231 fn anyhow_errors_map_to_internal() {
232 let api_err: ApiError = anyhow::anyhow!("unexpected failure").into();
233 assert!(matches!(api_err, ApiError::Internal(_)));
234 }
235}