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, ErrorCodeWire};
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 `501` for a capability this edge was not configured with.
55    ///
56    /// Used when BYOK is requested but no key-encryption key is set: the edge
57    /// declines rather than falling back to storing a provider key in the
58    /// clear. `501` rather than `503` because it will not start working on
59    /// retry — it needs configuration.
60    pub fn not_implemented(message: impl Into<String>) -> Self {
61        Self {
62            status: StatusCode::NOT_IMPLEMENTED,
63            code: "not_implemented",
64            message: message.into(),
65            internal: None,
66        }
67    }
68
69    /// A `409` for a request that conflicts with the tenant's current state.
70    pub fn conflict(message: impl Into<String>) -> Self {
71        Self {
72            status: StatusCode::CONFLICT,
73            code: "conflict",
74            message: message.into(),
75            internal: None,
76        }
77    }
78
79    /// A `502` when an upstream the edge depends on could not be reached.
80    ///
81    /// The detail is deliberately thin: an upstream error can embed the request
82    /// URL and headers, and for the LLM broker those headers carry a tenant's
83    /// provider key.
84    pub fn bad_gateway(message: impl Into<String>) -> Self {
85        Self {
86            status: StatusCode::BAD_GATEWAY,
87            code: "upstream.error",
88            message: message.into(),
89            internal: None,
90        }
91    }
92
93    /// A `409 llm_credential.missing` — this namespace has no provider key.
94    ///
95    /// Shares the code the data plane returns for the same condition (see
96    /// `from_brain`), so one code means one thing whichever surface produced
97    /// it, and a client's handling works against both.
98    pub fn llm_credential_missing(message: impl Into<String>) -> Self {
99        Self {
100            status: StatusCode::CONFLICT,
101            code: "llm_credential.missing",
102            message: message.into(),
103            internal: None,
104        }
105    }
106
107    /// A `404` for a resource this tenant has not created.
108    pub fn not_found(message: impl Into<String>) -> Self {
109        Self {
110            status: StatusCode::NOT_FOUND,
111            code: "not_found",
112            message: message.into(),
113            internal: None,
114        }
115    }
116
117    /// A `400` for a malformed request the edge itself rejects.
118    pub fn bad_request(message: impl Into<String>) -> Self {
119        Self {
120            status: StatusCode::BAD_REQUEST,
121            code: "bad_request",
122            message: message.into(),
123            internal: None,
124        }
125    }
126
127    /// Map a Brain client error to the matching HTTP status. Transport faults and
128    /// unavailable/exhausted verdicts are `503`; auth verdicts `401`/`403`;
129    /// validation/not-found/conflict map directly; anything else is `502`.
130    pub fn from_brain(err: &BrainError) -> Self {
131        match err {
132            BrainError::Io(_) | BrainError::Closed | BrainError::Timeout(_) => Self {
133                status: StatusCode::SERVICE_UNAVAILABLE,
134                code: "engine.unavailable",
135                // The OS error text describes the edge's own connectivity, not
136                // the caller's request.
137                message: "the memory engine is unavailable; retry shortly".to_string(),
138                internal: Some(format!("brain unavailable: {err}")),
139            },
140            // BYOK credential verdicts are matched on the CODE before the
141            // category fan-out below, because the category alone cannot carry
142            // them: `Conflict` is also an ordinary idempotency conflict, and
143            // telling an operator to register a provider key they already hold
144            // is worse than a generic error.
145            BrainError::Server {
146                code: ErrorCodeWire::LlmCredentialMissing,
147                message,
148                ..
149            } => Self {
150                // 409, deliberately NOT 503. The SDK retry lists are
151                // `(0, 503)` — a 503 here would make every client retry
152                // forever against a tenant that simply has not registered a
153                // key. This condition cannot self-heal, so it must be a 4xx.
154                status: StatusCode::CONFLICT,
155                code: "llm_credential.missing",
156                message: message.clone(),
157                internal: None,
158            },
159            BrainError::Server {
160                code: ErrorCodeWire::LlmCredentialUnavailable,
161                message,
162                ..
163            } => Self {
164                // 503 so clients DO retry: the key exists, the broker was
165                // briefly unreachable. Collapsing this into the 409 above
166                // would permanently fail writes for a healthy tenant.
167                status: StatusCode::SERVICE_UNAVAILABLE,
168                code: "llm_credential.unavailable",
169                message: message.clone(),
170                internal: None,
171            },
172            BrainError::Server {
173                category, message, ..
174            } => {
175                let (status, code) = match category {
176                    ErrorCategoryWire::Authentication => (StatusCode::UNAUTHORIZED, "unauthorized"),
177                    ErrorCategoryWire::Authorization => (StatusCode::FORBIDDEN, "forbidden"),
178                    ErrorCategoryWire::Unavailable | ErrorCategoryWire::ResourceExhausted => {
179                        (StatusCode::SERVICE_UNAVAILABLE, "engine.unavailable")
180                    }
181                    ErrorCategoryWire::Validation => (StatusCode::BAD_REQUEST, "bad_request"),
182                    ErrorCategoryWire::NotFound => (StatusCode::NOT_FOUND, "not_found"),
183                    ErrorCategoryWire::Conflict => (StatusCode::CONFLICT, "conflict"),
184                    ErrorCategoryWire::Protocol | ErrorCategoryWire::Internal => {
185                        (StatusCode::BAD_GATEWAY, "engine.error")
186                    }
187                };
188                // Brain's own message is about THIS caller's request, so it is
189                // returned as-is for the statuses that describe the request.
190                // The 5xx arms are the edge reporting its own trouble.
191                let internal = if status.is_server_error() {
192                    Some(format!("brain {category:?}: {message}"))
193                } else {
194                    None
195                };
196                Self {
197                    status,
198                    code,
199                    message: if internal.is_some() {
200                        "the memory engine returned an error".to_string()
201                    } else {
202                        message.clone()
203                    },
204                    internal,
205                }
206            }
207            // Frame-codec faults, protocol violations, version mismatches: all
208            // describe the edge's conversation with Brain, none describe the
209            // caller's request.
210            other => Self {
211                status: StatusCode::BAD_GATEWAY,
212                code: "engine.error",
213                message: "the memory engine returned an error".to_string(),
214                internal: Some(format!("brain error: {other}")),
215            },
216        }
217    }
218}
219
220impl ApiError {
221    /// A `500` for a fault the edge itself could not classify.
222    #[must_use]
223    pub fn internal(detail: impl Into<String>) -> Self {
224        Self {
225            status: StatusCode::INTERNAL_SERVER_ERROR,
226            code: "internal",
227            message: "internal error".to_string(),
228            internal: Some(detail.into()),
229        }
230    }
231
232    /// Emit the withheld detail to the log, where the request id ties it back
233    /// to the response the caller saw. Called once, on the way out.
234    fn log_internal(&self) {
235        if let Some(detail) = &self.internal {
236            tracing::warn!(
237                status = self.status.as_u16(),
238                code = self.code,
239                detail = %detail,
240                "request failed"
241            );
242        }
243    }
244
245    /// The HTTP status this error renders as (test/introspection).
246    #[cfg(test)]
247    #[must_use]
248    pub const fn status(&self) -> StatusCode {
249        self.status
250    }
251
252    /// The message the caller is told (test/introspection).
253    #[cfg(test)]
254    #[must_use]
255    pub fn message(&self) -> &str {
256        &self.message
257    }
258}
259
260impl IntoResponse for ApiError {
261    fn into_response(self) -> Response {
262        self.log_internal();
263        let body = ErrorBody {
264            error: ErrorInner {
265                code: self.code,
266                message: &self.message,
267            },
268        };
269        (self.status, Json(body)).into_response()
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use brain_db_sdk::wire::types::{ErrorCodeWire, ErrorResponse};
277
278    fn server(category: ErrorCategoryWire, message: &str) -> BrainError {
279        BrainError::from_server(ErrorResponse {
280            code: ErrorCodeWire::PermissionDenied,
281            category,
282            message: message.to_string(),
283            details: None,
284            retry_after_ms: None,
285        })
286    }
287
288    fn server_with_code(
289        code: ErrorCodeWire,
290        category: ErrorCategoryWire,
291        message: &str,
292    ) -> BrainError {
293        BrainError::from_server(ErrorResponse {
294            code,
295            category,
296            message: message.to_string(),
297            details: None,
298            retry_after_ms: None,
299        })
300    }
301
302    #[test]
303    fn a_missing_llm_credential_is_a_409_not_a_503() {
304        // The status choice is the contract. Every SDK's retry list is
305        // `(0, 503)`, so mapping this to 503 would make clients retry forever
306        // against a tenant that has simply not registered a provider key.
307        let err = ApiError::from_brain(&server_with_code(
308            ErrorCodeWire::LlmCredentialMissing,
309            ErrorCategoryWire::Conflict,
310            "no provider key registered for acme",
311        ));
312        assert_eq!(err.status(), StatusCode::CONFLICT);
313        assert_eq!(err.code, "llm_credential.missing");
314        assert_eq!(err.message(), "no provider key registered for acme");
315        assert!(
316            err.internal.is_none(),
317            "this is about the caller's workspace, not the edge's own trouble"
318        );
319    }
320
321    #[test]
322    fn a_transient_credential_failure_is_a_503_so_clients_retry() {
323        let err = ApiError::from_brain(&server_with_code(
324            ErrorCodeWire::LlmCredentialUnavailable,
325            ErrorCategoryWire::Unavailable,
326            "credential broker unreachable",
327        ));
328        assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
329        assert_eq!(err.code, "llm_credential.unavailable");
330    }
331
332    #[test]
333    fn an_ordinary_conflict_is_not_reported_as_a_missing_credential() {
334        // `Conflict` also covers idempotency conflicts. Matching on category
335        // alone would tell operators to configure a key they already have.
336        let err = ApiError::from_brain(&server_with_code(
337            ErrorCodeWire::IdempotencyConflict,
338            ErrorCategoryWire::Conflict,
339            "same request id, different body",
340        ));
341        assert_eq!(err.status(), StatusCode::CONFLICT);
342        assert_eq!(err.code, "conflict");
343    }
344
345    #[test]
346    fn a_4xx_returns_the_engine_message_because_it_is_about_this_request() {
347        // Validation / not-found / conflict describe what the caller asked for,
348        // so withholding the detail would only make the API harder to use.
349        let err = ApiError::from_brain(&server(ErrorCategoryWire::Validation, "k must be 1..=100"));
350        assert_eq!(err.status(), StatusCode::BAD_REQUEST);
351        assert_eq!(err.message(), "k must be 1..=100");
352        assert!(err.internal.is_none(), "nothing to withhold on a 4xx");
353    }
354
355    #[test]
356    fn a_5xx_withholds_the_engine_message() {
357        // `Internal` is the engine reporting its own trouble. Forwarding that
358        // text tells the caller about the edge's conversation with its
359        // database rather than about their request.
360        let err = ApiError::from_brain(&server(
361            ErrorCategoryWire::Internal,
362            "shard 7 wal replay failed at lsn 918273",
363        ));
364        assert_eq!(err.status(), StatusCode::BAD_GATEWAY);
365        assert!(
366            !err.message().contains("shard 7") && !err.message().contains("lsn"),
367            "internal state must not reach the caller, got: {}",
368            err.message()
369        );
370        // Withheld, not discarded: it is logged with the request id.
371        assert!(
372            err.internal
373                .as_deref()
374                .unwrap_or_default()
375                .contains("wal replay failed"),
376            "the detail must still be available to the log"
377        );
378    }
379
380    #[test]
381    fn a_transport_fault_withholds_the_os_error() {
382        // The OS text describes the edge's own connectivity to its backend.
383        let err = ApiError::from_brain(&BrainError::Io(std::io::Error::from(
384            std::io::ErrorKind::ConnectionRefused,
385        )));
386        assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
387        assert!(
388            !err.message().to_lowercase().contains("refused"),
389            "the OS error must not reach the caller, got: {}",
390            err.message()
391        );
392        assert!(err.internal.is_some(), "and must still reach the log");
393    }
394
395    #[test]
396    fn a_protocol_fault_withholds_the_protocol_detail() {
397        // Frame-codec faults and version mismatches are entirely about the
398        // edge↔Brain link; a caller can do nothing with them.
399        let err = ApiError::from_brain(&BrainError::Protocol(
400            "expected WELCOME on stream 0, got 0x00FF".to_string(),
401        ));
402        assert_eq!(err.status(), StatusCode::BAD_GATEWAY);
403        assert!(
404            !err.message().contains("WELCOME") && !err.message().contains("0x00FF"),
405            "protocol internals must not reach the caller, got: {}",
406            err.message()
407        );
408        assert!(err.internal.is_some());
409    }
410
411    #[test]
412    fn every_5xx_arm_withholds_something_and_every_4xx_arm_does_not() {
413        // Exhaustive over the category table, so a new arm cannot be added
414        // without a decision being made about what the caller is told.
415        let categories = [
416            ErrorCategoryWire::Protocol,
417            ErrorCategoryWire::Authentication,
418            ErrorCategoryWire::Authorization,
419            ErrorCategoryWire::Validation,
420            ErrorCategoryWire::NotFound,
421            ErrorCategoryWire::Conflict,
422            ErrorCategoryWire::ResourceExhausted,
423            ErrorCategoryWire::Internal,
424            ErrorCategoryWire::Unavailable,
425        ];
426        for category in categories {
427            let err = ApiError::from_brain(&server(category, "engine detail"));
428            assert_eq!(
429                err.status().is_server_error(),
430                err.internal.is_some(),
431                "{category:?}: a 5xx must withhold, a 4xx must not"
432            );
433        }
434    }
435}