Skip to main content

brain_edge/error/
mod.rs

1//! HTTP error type — one JSON error envelope, mapped from Brain's taxonomy.
2//!
3//! A `5xx` body deliberately carries no internal detail. `BrainError` renders
4//! frame-codec faults, protocol violations and version mismatches into their
5//! `Display` text, and forwarding that to an HTTP caller tells them about the
6//! edge's conversation with its database rather than about their request. The
7//! detail is logged with the request id instead, so an operator can still find
8//! it — see [`ApiError::log_internal`].
9
10use axum::Json;
11use axum::http::StatusCode;
12use axum::response::{IntoResponse, Response};
13use brain_db_sdk::BrainError;
14use brain_db_sdk::wire::types::ErrorCategoryWire;
15use serde::Serialize;
16
17/// A structured API error. Serializes to `{ "error": { code, message } }` with
18/// the matching HTTP status.
19#[derive(Debug)]
20pub struct ApiError {
21    status: StatusCode,
22    code: &'static str,
23    /// What the caller is told.
24    message: String,
25    /// What the caller is NOT told: the underlying failure, logged instead.
26    ///
27    /// `Some` only for statuses that describe a fault on this side. A `4xx`
28    /// message is about the caller's own request and is safe to return.
29    internal: Option<String>,
30}
31
32#[derive(Serialize)]
33struct ErrorBody<'a> {
34    error: ErrorInner<'a>,
35}
36
37#[derive(Serialize)]
38struct ErrorInner<'a> {
39    code: &'a str,
40    message: &'a str,
41}
42
43impl ApiError {
44    /// A `401` for a missing/blank credential.
45    pub fn unauthorized(message: impl Into<String>) -> Self {
46        Self {
47            status: StatusCode::UNAUTHORIZED,
48            code: "unauthorized",
49            message: message.into(),
50            internal: None,
51        }
52    }
53
54    /// A `400` for a malformed request the edge itself rejects.
55    pub fn bad_request(message: impl Into<String>) -> Self {
56        Self {
57            status: StatusCode::BAD_REQUEST,
58            code: "bad_request",
59            message: message.into(),
60            internal: None,
61        }
62    }
63
64    /// Map a Brain client error to the matching HTTP status. Transport faults and
65    /// unavailable/exhausted verdicts are `503`; auth verdicts `401`/`403`;
66    /// validation/not-found/conflict map directly; anything else is `502`.
67    pub fn from_brain(err: &BrainError) -> Self {
68        match err {
69            BrainError::Io(_) | BrainError::Closed | BrainError::Timeout(_) => Self {
70                status: StatusCode::SERVICE_UNAVAILABLE,
71                code: "engine.unavailable",
72                // The OS error text describes the edge's own connectivity, not
73                // the caller's request.
74                message: "the memory engine is unavailable; retry shortly".to_string(),
75                internal: Some(format!("brain unavailable: {err}")),
76            },
77            BrainError::Server {
78                category, message, ..
79            } => {
80                let (status, code) = match category {
81                    ErrorCategoryWire::Authentication => (StatusCode::UNAUTHORIZED, "unauthorized"),
82                    ErrorCategoryWire::Authorization => (StatusCode::FORBIDDEN, "forbidden"),
83                    ErrorCategoryWire::Unavailable | ErrorCategoryWire::ResourceExhausted => {
84                        (StatusCode::SERVICE_UNAVAILABLE, "engine.unavailable")
85                    }
86                    ErrorCategoryWire::Validation => (StatusCode::BAD_REQUEST, "bad_request"),
87                    ErrorCategoryWire::NotFound => (StatusCode::NOT_FOUND, "not_found"),
88                    ErrorCategoryWire::Conflict => (StatusCode::CONFLICT, "conflict"),
89                    ErrorCategoryWire::Protocol | ErrorCategoryWire::Internal => {
90                        (StatusCode::BAD_GATEWAY, "engine.error")
91                    }
92                };
93                // Brain's own message is about THIS caller's request, so it is
94                // returned as-is for the statuses that describe the request.
95                // The 5xx arms are the edge reporting its own trouble.
96                let internal = if status.is_server_error() {
97                    Some(format!("brain {category:?}: {message}"))
98                } else {
99                    None
100                };
101                Self {
102                    status,
103                    code,
104                    message: if internal.is_some() {
105                        "the memory engine returned an error".to_string()
106                    } else {
107                        message.clone()
108                    },
109                    internal,
110                }
111            }
112            // Frame-codec faults, protocol violations, version mismatches: all
113            // describe the edge's conversation with Brain, none describe the
114            // caller's request.
115            other => Self {
116                status: StatusCode::BAD_GATEWAY,
117                code: "engine.error",
118                message: "the memory engine returned an error".to_string(),
119                internal: Some(format!("brain error: {other}")),
120            },
121        }
122    }
123}
124
125impl ApiError {
126    /// A `500` for a fault the edge itself could not classify.
127    #[must_use]
128    pub fn internal(detail: impl Into<String>) -> Self {
129        Self {
130            status: StatusCode::INTERNAL_SERVER_ERROR,
131            code: "internal",
132            message: "internal error".to_string(),
133            internal: Some(detail.into()),
134        }
135    }
136
137    /// Emit the withheld detail to the log, where the request id ties it back
138    /// to the response the caller saw. Called once, on the way out.
139    fn log_internal(&self) {
140        if let Some(detail) = &self.internal {
141            tracing::warn!(
142                status = self.status.as_u16(),
143                code = self.code,
144                detail = %detail,
145                "request failed"
146            );
147        }
148    }
149
150    /// The HTTP status this error renders as (test/introspection).
151    #[cfg(test)]
152    #[must_use]
153    pub const fn status(&self) -> StatusCode {
154        self.status
155    }
156
157    /// The message the caller is told (test/introspection).
158    #[cfg(test)]
159    #[must_use]
160    pub fn message(&self) -> &str {
161        &self.message
162    }
163}
164
165impl IntoResponse for ApiError {
166    fn into_response(self) -> Response {
167        self.log_internal();
168        let body = ErrorBody {
169            error: ErrorInner {
170                code: self.code,
171                message: &self.message,
172            },
173        };
174        (self.status, Json(body)).into_response()
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181    use brain_db_sdk::wire::types::{ErrorCodeWire, ErrorResponse};
182
183    fn server(category: ErrorCategoryWire, message: &str) -> BrainError {
184        BrainError::from_server(ErrorResponse {
185            code: ErrorCodeWire::PermissionDenied,
186            category,
187            message: message.to_string(),
188            details: None,
189            retry_after_ms: None,
190        })
191    }
192
193    #[test]
194    fn a_4xx_returns_the_engine_message_because_it_is_about_this_request() {
195        // Validation / not-found / conflict describe what the caller asked for,
196        // so withholding the detail would only make the API harder to use.
197        let err = ApiError::from_brain(&server(ErrorCategoryWire::Validation, "k must be 1..=100"));
198        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
199        assert_eq!(err.message(), "k must be 1..=100");
200        assert!(err.internal.is_none(), "nothing to withhold on a 4xx");
201    }
202
203    #[test]
204    fn a_5xx_withholds_the_engine_message() {
205        // `Internal` is the engine reporting its own trouble. Forwarding that
206        // text tells the caller about the edge's conversation with its
207        // database rather than about their request.
208        let err = ApiError::from_brain(&server(
209            ErrorCategoryWire::Internal,
210            "shard 7 wal replay failed at lsn 918273",
211        ));
212        assert_eq!(err.status(), StatusCode::BAD_GATEWAY);
213        assert!(
214            !err.message().contains("shard 7") && !err.message().contains("lsn"),
215            "internal state must not reach the caller, got: {}",
216            err.message()
217        );
218        // Withheld, not discarded: it is logged with the request id.
219        assert!(
220            err.internal
221                .as_deref()
222                .unwrap_or_default()
223                .contains("wal replay failed"),
224            "the detail must still be available to the log"
225        );
226    }
227
228    #[test]
229    fn a_transport_fault_withholds_the_os_error() {
230        // The OS text describes the edge's own connectivity to its backend.
231        let err = ApiError::from_brain(&BrainError::Io(std::io::Error::from(
232            std::io::ErrorKind::ConnectionRefused,
233        )));
234        assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
235        assert!(
236            !err.message().to_lowercase().contains("refused"),
237            "the OS error must not reach the caller, got: {}",
238            err.message()
239        );
240        assert!(err.internal.is_some(), "and must still reach the log");
241    }
242
243    #[test]
244    fn a_protocol_fault_withholds_the_protocol_detail() {
245        // Frame-codec faults and version mismatches are entirely about the
246        // edge↔Brain link; a caller can do nothing with them.
247        let err = ApiError::from_brain(&BrainError::Protocol(
248            "expected WELCOME on stream 0, got 0x00FF".to_string(),
249        ));
250        assert_eq!(err.status(), StatusCode::BAD_GATEWAY);
251        assert!(
252            !err.message().contains("WELCOME") && !err.message().contains("0x00FF"),
253            "protocol internals must not reach the caller, got: {}",
254            err.message()
255        );
256        assert!(err.internal.is_some());
257    }
258
259    #[test]
260    fn every_5xx_arm_withholds_something_and_every_4xx_arm_does_not() {
261        // Exhaustive over the category table, so a new arm cannot be added
262        // without a decision being made about what the caller is told.
263        let categories = [
264            ErrorCategoryWire::Protocol,
265            ErrorCategoryWire::Authentication,
266            ErrorCategoryWire::Authorization,
267            ErrorCategoryWire::Validation,
268            ErrorCategoryWire::NotFound,
269            ErrorCategoryWire::Conflict,
270            ErrorCategoryWire::ResourceExhausted,
271            ErrorCategoryWire::Internal,
272            ErrorCategoryWire::Unavailable,
273        ];
274        for category in categories {
275            let err = ApiError::from_brain(&server(category, "engine detail"));
276            assert_eq!(
277                err.status().is_server_error(),
278                err.internal.is_some(),
279                "{category:?}: a 5xx must withhold, a 4xx must not"
280            );
281        }
282    }
283}