Skip to main content

bhtune_server/
error.rs

1//! [`ApiError`]: the one error type every route handler returns, and its mapping onto an HTTP
2//! status code plus a JSON `{"error": "..."}` body.
3
4use axum::Json;
5use axum::http::{HeaderValue, StatusCode, header};
6use axum::response::{IntoResponse, Response};
7use serde::Serialize;
8use utoipa::ToSchema;
9
10/// Every way a request can fail, already carrying the HTTP status it maps to -- handlers
11/// return `Result<_, ApiError>` and let `?` do the conversion (see the `From` impls below),
12/// the same "one error enum, converted at the boundary" shape the `bhtune` CLI's commands use
13/// with `anyhow::Result`.
14#[derive(Debug)]
15pub enum ApiError {
16    /// The requested resource doesn't exist (a template name, a run id). 404.
17    NotFound(String),
18    /// The request conflicts with existing state (a name collision on create, a delete
19    /// blocked by a foreign-key reference). 409.
20    Conflict(String),
21    /// The request body/query itself is malformed or fails domain validation
22    /// (`DcsTemplate::validate`, an unparseable filter value axum's extractors didn't already
23    /// reject). 400.
24    BadRequest(String),
25    /// The request was authenticated but is not permitted. 403.
26    Forbidden(String),
27    /// A per-client or per-session limit was exceeded. 429, with a `Retry-After` header.
28    TooManyRequests {
29        message: String,
30        retry_after_secs: u64,
31    },
32    /// The public demo has exhausted a global capacity limit. 503, with a `Retry-After`
33    /// header. Keeping this distinct from per-client throttling lets callers avoid telling a
34    /// client that its own request rate caused shared service saturation.
35    GlobalCapacity {
36        message: String,
37        retry_after_secs: u64,
38    },
39    /// No valid demo identity was supplied. 401.
40    Unauthorized(String),
41    /// Anything else: a database connection/query failure, or any other unexpected error.
42    /// Deliberately doesn't echo the underlying error's `Display` text into the response body
43    /// (logged via `tracing::error!` instead) -- an internal error's detail is for the
44    /// server's own logs, not a client that can't act on it. 500.
45    Internal(anyhow::Error),
46}
47
48/// The JSON body of every non-2xx response: `{"error": "<message>"}`. `pub`/`ToSchema` so
49/// every fallible `#[utoipa::path]` response can reference it (`body = ErrorBody`) and the
50/// generated OpenAPI spec -- and therefore the generated frontend TS client -- accurately
51/// types error bodies instead of `content?: never`.
52#[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    /// [`bhtune_db::DbError::TemplateInUse`] is the one variant a client can actually act on
108    /// (stop trying to delete a template still referenced by a saved loop) -- everything else
109    /// is an infrastructure-level failure the client can't distinguish or fix, so it collapses
110    /// to [`ApiError::Internal`].
111    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
127/// Returns `value`, or an internal error when a prior check should have proved it present.
128///
129/// A missing value here is a server bug, not a client error, so the response is 500.
130pub(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}