1use 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#[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 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 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 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 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 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 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 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 message: "the memory engine is unavailable; retry shortly".to_string(),
138 internal: Some(format!("brain unavailable: {err}")),
139 },
140 BrainError::Server {
146 code: ErrorCodeWire::LlmCredentialMissing,
147 message,
148 ..
149 } => Self {
150 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 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 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 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 #[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 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 #[cfg(test)]
247 #[must_use]
248 pub const fn status(&self) -> StatusCode {
249 self.status
250 }
251
252 #[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 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 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 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 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 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 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 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 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}