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 `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            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    /// [`bhtune_db::DbError::TemplateInUse`] is the one variant a client can actually act on
104    /// (stop trying to delete a template still referenced by a saved loop) -- everything else
105    /// is an infrastructure-level failure the client can't distinguish or fix, so it collapses
106    /// to [`ApiError::Internal`].
107    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}