use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use brain_db_sdk::BrainError;
use brain_db_sdk::wire::types::ErrorCategoryWire;
use serde::Serialize;
#[derive(Debug)]
pub struct ApiError {
status: StatusCode,
code: &'static str,
message: String,
internal: Option<String>,
}
#[derive(Serialize)]
struct ErrorBody<'a> {
error: ErrorInner<'a>,
}
#[derive(Serialize)]
struct ErrorInner<'a> {
code: &'a str,
message: &'a str,
}
impl ApiError {
pub fn unauthorized(message: impl Into<String>) -> Self {
Self {
status: StatusCode::UNAUTHORIZED,
code: "unauthorized",
message: message.into(),
internal: None,
}
}
pub fn bad_request(message: impl Into<String>) -> Self {
Self {
status: StatusCode::BAD_REQUEST,
code: "bad_request",
message: message.into(),
internal: None,
}
}
pub fn from_brain(err: &BrainError) -> Self {
match err {
BrainError::Io(_) | BrainError::Closed | BrainError::Timeout(_) => Self {
status: StatusCode::SERVICE_UNAVAILABLE,
code: "engine.unavailable",
message: "the memory engine is unavailable; retry shortly".to_string(),
internal: Some(format!("brain unavailable: {err}")),
},
BrainError::Server {
category, message, ..
} => {
let (status, code) = match category {
ErrorCategoryWire::Authentication => (StatusCode::UNAUTHORIZED, "unauthorized"),
ErrorCategoryWire::Authorization => (StatusCode::FORBIDDEN, "forbidden"),
ErrorCategoryWire::Unavailable | ErrorCategoryWire::ResourceExhausted => {
(StatusCode::SERVICE_UNAVAILABLE, "engine.unavailable")
}
ErrorCategoryWire::Validation => (StatusCode::BAD_REQUEST, "bad_request"),
ErrorCategoryWire::NotFound => (StatusCode::NOT_FOUND, "not_found"),
ErrorCategoryWire::Conflict => (StatusCode::CONFLICT, "conflict"),
ErrorCategoryWire::Protocol | ErrorCategoryWire::Internal => {
(StatusCode::BAD_GATEWAY, "engine.error")
}
};
let internal = if status.is_server_error() {
Some(format!("brain {category:?}: {message}"))
} else {
None
};
Self {
status,
code,
message: if internal.is_some() {
"the memory engine returned an error".to_string()
} else {
message.clone()
},
internal,
}
}
other => Self {
status: StatusCode::BAD_GATEWAY,
code: "engine.error",
message: "the memory engine returned an error".to_string(),
internal: Some(format!("brain error: {other}")),
},
}
}
}
impl ApiError {
#[must_use]
pub fn internal(detail: impl Into<String>) -> Self {
Self {
status: StatusCode::INTERNAL_SERVER_ERROR,
code: "internal",
message: "internal error".to_string(),
internal: Some(detail.into()),
}
}
fn log_internal(&self) {
if let Some(detail) = &self.internal {
tracing::warn!(
status = self.status.as_u16(),
code = self.code,
detail = %detail,
"request failed"
);
}
}
#[cfg(test)]
#[must_use]
pub const fn status(&self) -> StatusCode {
self.status
}
#[cfg(test)]
#[must_use]
pub fn message(&self) -> &str {
&self.message
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
self.log_internal();
let body = ErrorBody {
error: ErrorInner {
code: self.code,
message: &self.message,
},
};
(self.status, Json(body)).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
use brain_db_sdk::wire::types::{ErrorCodeWire, ErrorResponse};
fn server(category: ErrorCategoryWire, message: &str) -> BrainError {
BrainError::from_server(ErrorResponse {
code: ErrorCodeWire::PermissionDenied,
category,
message: message.to_string(),
details: None,
retry_after_ms: None,
})
}
#[test]
fn a_4xx_returns_the_engine_message_because_it_is_about_this_request() {
let err = ApiError::from_brain(&server(ErrorCategoryWire::Validation, "k must be 1..=100"));
assert_eq!(err.status(), StatusCode::BAD_REQUEST);
assert_eq!(err.message(), "k must be 1..=100");
assert!(err.internal.is_none(), "nothing to withhold on a 4xx");
}
#[test]
fn a_5xx_withholds_the_engine_message() {
let err = ApiError::from_brain(&server(
ErrorCategoryWire::Internal,
"shard 7 wal replay failed at lsn 918273",
));
assert_eq!(err.status(), StatusCode::BAD_GATEWAY);
assert!(
!err.message().contains("shard 7") && !err.message().contains("lsn"),
"internal state must not reach the caller, got: {}",
err.message()
);
assert!(
err.internal
.as_deref()
.unwrap_or_default()
.contains("wal replay failed"),
"the detail must still be available to the log"
);
}
#[test]
fn a_transport_fault_withholds_the_os_error() {
let err = ApiError::from_brain(&BrainError::Io(std::io::Error::from(
std::io::ErrorKind::ConnectionRefused,
)));
assert_eq!(err.status(), StatusCode::SERVICE_UNAVAILABLE);
assert!(
!err.message().to_lowercase().contains("refused"),
"the OS error must not reach the caller, got: {}",
err.message()
);
assert!(err.internal.is_some(), "and must still reach the log");
}
#[test]
fn a_protocol_fault_withholds_the_protocol_detail() {
let err = ApiError::from_brain(&BrainError::Protocol(
"expected WELCOME on stream 0, got 0x00FF".to_string(),
));
assert_eq!(err.status(), StatusCode::BAD_GATEWAY);
assert!(
!err.message().contains("WELCOME") && !err.message().contains("0x00FF"),
"protocol internals must not reach the caller, got: {}",
err.message()
);
assert!(err.internal.is_some());
}
#[test]
fn every_5xx_arm_withholds_something_and_every_4xx_arm_does_not() {
let categories = [
ErrorCategoryWire::Protocol,
ErrorCategoryWire::Authentication,
ErrorCategoryWire::Authorization,
ErrorCategoryWire::Validation,
ErrorCategoryWire::NotFound,
ErrorCategoryWire::Conflict,
ErrorCategoryWire::ResourceExhausted,
ErrorCategoryWire::Internal,
ErrorCategoryWire::Unavailable,
];
for category in categories {
let err = ApiError::from_brain(&server(category, "engine detail"));
assert_eq!(
err.status().is_server_error(),
err.internal.is_some(),
"{category:?}: a 5xx must withhold, a 4xx must not"
);
}
}
}