1use 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#[derive(Debug)]
20pub struct ApiError {
21 status: StatusCode,
22 code: &'static str,
23 message: String,
25 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 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 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 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 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 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 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 #[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 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 #[cfg(test)]
152 #[must_use]
153 pub const fn status(&self) -> StatusCode {
154 self.status
155 }
156
157 #[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 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 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 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 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 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 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}