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 #[allow(
93 clippy::expect_used,
94 reason = "u64 decimal text is always a valid Retry-After header value"
95 )]
96 response.headers_mut().insert(
97 header::RETRY_AFTER,
98 HeaderValue::from_str(&retry_after_secs.to_string())
99 .expect("an integer is always a valid Retry-After header value"),
100 );
101 }
102 response
103 }
104}
105
106impl From<bhtune_db::DbError> for ApiError {
107 fn from(err: bhtune_db::DbError) -> Self {
112 match err {
113 bhtune_db::DbError::TemplateInUse { id } => ApiError::Conflict(format!(
114 "template {id} is still referenced by one or more saved loops and cannot be deleted"
115 )),
116 other => ApiError::Internal(other.into()),
117 }
118 }
119}
120
121impl From<anyhow::Error> for ApiError {
122 fn from(err: anyhow::Error) -> Self {
123 ApiError::Internal(err)
124 }
125}
126
127pub(crate) fn require_present<T>(value: Option<T>, message: &'static str) -> Result<T, ApiError> {
131 let Some(value) = value else {
132 return Err(ApiError::Internal(anyhow::anyhow!(message)));
133 };
134 Ok(value)
135}
136
137#[cfg(test)]
138mod tests {
139 use super::*;
140 use axum::body::to_bytes;
141
142 async fn body_json(response: Response) -> serde_json::Value {
143 let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
144 serde_json::from_slice(&bytes).unwrap()
145 }
146
147 #[tokio::test]
148 async fn not_found_maps_to_404_with_the_message() {
149 let response = ApiError::NotFound("no template named 'X'".to_string()).into_response();
150 assert_eq!(response.status(), StatusCode::NOT_FOUND);
151 assert_eq!(
152 body_json(response).await,
153 serde_json::json!({"error": "no template named 'X'"})
154 );
155 }
156
157 #[tokio::test]
158 async fn conflict_maps_to_409_with_the_message() {
159 let response = ApiError::Conflict("already exists".to_string()).into_response();
160 assert_eq!(response.status(), StatusCode::CONFLICT);
161 assert_eq!(
162 body_json(response).await,
163 serde_json::json!({"error": "already exists"})
164 );
165 }
166
167 #[tokio::test]
168 async fn bad_request_maps_to_400_with_the_message() {
169 let response = ApiError::BadRequest("invalid".to_string()).into_response();
170 assert_eq!(response.status(), StatusCode::BAD_REQUEST);
171 assert_eq!(
172 body_json(response).await,
173 serde_json::json!({"error": "invalid"})
174 );
175 }
176
177 #[tokio::test]
178 async fn forbidden_maps_to_403_with_the_message() {
179 let response = ApiError::Forbidden("not permitted".to_string()).into_response();
180 assert_eq!(response.status(), StatusCode::FORBIDDEN);
181 assert_eq!(
182 body_json(response).await,
183 serde_json::json!({"error": "not permitted"})
184 );
185 }
186
187 #[tokio::test]
188 async fn too_many_requests_maps_to_429_with_retry_after() {
189 let response = ApiError::TooManyRequests {
190 message: "slow down".to_string(),
191 retry_after_secs: 17,
192 }
193 .into_response();
194 assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
195 assert_eq!(response.headers()[header::RETRY_AFTER], "17");
196 assert_eq!(
197 body_json(response).await,
198 serde_json::json!({"error": "slow down"})
199 );
200 }
201
202 #[tokio::test]
203 async fn global_capacity_maps_to_503_with_retry_after() {
204 let response = ApiError::GlobalCapacity {
205 message: "demo capacity exhausted".to_string(),
206 retry_after_secs: 3,
207 }
208 .into_response();
209 assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
210 assert_eq!(response.headers()[header::RETRY_AFTER], "3");
211 assert_eq!(
212 body_json(response).await,
213 serde_json::json!({"error": "demo capacity exhausted"})
214 );
215 }
216
217 #[tokio::test]
218 async fn internal_maps_to_500_and_never_leaks_the_underlying_message() {
219 let response =
220 ApiError::Internal(anyhow::anyhow!("secret db path leaked here")).into_response();
221 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
222 assert_eq!(
223 body_json(response).await,
224 serde_json::json!({"error": "internal server error"})
225 );
226 }
227
228 #[tokio::test]
229 async fn template_in_use_db_error_maps_to_409() {
230 let api_err: ApiError = bhtune_db::DbError::TemplateInUse { id: 7 }.into();
231 assert!(matches!(api_err, ApiError::Conflict(_)));
232 let response = api_err.into_response();
233 assert_eq!(response.status(), StatusCode::CONFLICT);
234 }
235
236 #[tokio::test]
237 async fn other_db_errors_map_to_internal() {
238 let api_err: ApiError = bhtune_db::DbError::InvalidBackup("bad file".to_string()).into();
239 assert!(matches!(api_err, ApiError::Internal(_)));
240 let response = api_err.into_response();
241 assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
242 }
243
244 #[test]
245 fn anyhow_errors_map_to_internal() {
246 let api_err: ApiError = anyhow::anyhow!("unexpected failure").into();
247 assert!(matches!(api_err, ApiError::Internal(_)));
248 }
249
250 #[test]
251 fn require_present_returns_the_value_or_an_internal_error() {
252 let present = require_present(Some(7), "missing value").expect("Some is present");
253 assert_eq!(present, 7);
254 let missing =
255 require_present(None::<i32>, "missing value").expect_err("None is an internal error");
256 assert!(format!("{missing:?}").contains("missing value"));
257 assert_eq!(
258 missing.into_response().status(),
259 StatusCode::INTERNAL_SERVER_ERROR
260 );
261 }
262}