Skip to main content

kasl_server/
error.rs

1//! One error shape for the whole API.
2//!
3//! kasl agents parse what comes back, so a failure has to be as predictable as
4//! a success: always JSON, always the same field. A handler returning a bare
5//! status leaves the agent guessing whether to retry, fix its payload, or tell
6//! the employee something is wrong.
7
8use axum::{
9    Json,
10    http::StatusCode,
11    response::{IntoResponse, Response},
12};
13use serde_json::json;
14
15/// A failed request, with the status the client should act on.
16#[derive(Debug)]
17pub struct ApiError {
18    status: StatusCode,
19    message: String,
20}
21
22impl ApiError {
23    pub fn new(status: StatusCode, message: impl Into<String>) -> Self {
24        Self {
25            status,
26            message: message.into(),
27        }
28    }
29
30    /// The payload was understood but is not acceptable - the agent must fix
31    /// it rather than retry.
32    pub fn bad_request(message: impl Into<String>) -> Self {
33        Self::new(StatusCode::BAD_REQUEST, message)
34    }
35
36    /// Whose fault this was. A batch reads it to tell a day it should give up
37    /// on from one it should send again later.
38    pub fn status(&self) -> StatusCode {
39        self.status
40    }
41}
42
43impl std::fmt::Display for ApiError {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        f.write_str(&self.message)
46    }
47}
48
49impl IntoResponse for ApiError {
50    fn into_response(self) -> Response {
51        // Server-side faults are logged in full and reported in outline: an
52        // agent has no use for a database error, and the message could carry
53        // details of someone else's data.
54        if self.status.is_server_error() {
55            tracing::error!(status = %self.status, error = %self.message, "request failed");
56            return (self.status, Json(json!({ "error": "internal server error" }))).into_response();
57        }
58
59        (self.status, Json(json!({ "error": self.message }))).into_response()
60    }
61}
62
63impl From<anyhow::Error> for ApiError {
64    /// Anything that failed inside the server and has no better status. The
65    /// message is logged in full and answered in outline, as with any 500.
66    fn from(error: anyhow::Error) -> Self {
67        Self::new(StatusCode::INTERNAL_SERVER_ERROR, format!("{error:#}"))
68    }
69}
70
71impl From<sqlx::Error> for ApiError {
72    fn from(error: sqlx::Error) -> Self {
73        Self::new(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
74    }
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80    use axum::body::to_bytes;
81
82    async fn body_of(error: ApiError) -> serde_json::Value {
83        let response = error.into_response();
84        let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
85        serde_json::from_slice(&bytes).unwrap()
86    }
87
88    #[tokio::test]
89    async fn client_errors_explain_themselves() {
90        let body = body_of(ApiError::bad_request("date is not a calendar date")).await;
91        assert_eq!(body["error"], "date is not a calendar date");
92    }
93
94    #[tokio::test]
95    async fn server_errors_do_not_leak_their_cause() {
96        let body = body_of(ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "relation \"users\" does not exist")).await;
97        assert_eq!(body["error"], "internal server error", "internals must not reach the client");
98    }
99
100    #[test]
101    fn the_status_survives_into_the_response() {
102        let response = ApiError::new(StatusCode::UNAUTHORIZED, "nope").into_response();
103        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
104    }
105}