Skip to main content

lora_server/app/
errors.rs

1use axum::{
2    extract::rejection::JsonRejection,
3    http::StatusCode,
4    response::{IntoResponse, Response},
5    Json,
6};
7use lora_database::{LoraError, LoraErrorCategory, LoraErrorCode};
8use serde::Serialize;
9
10/// Structured error body returned by every fallible HTTP endpoint.
11///
12/// Wire shape:
13/// ```json
14/// { "error": { "code": "LORA_PARSE", "message": "...", "category": "client" } }
15/// ```
16///
17/// `code` is a stable wire string from the [`LoraErrorCode`] catalog and
18/// is the field bindings / dashboards / tests should match on. `message`
19/// is human-friendly and may be reworded between releases. `category` is
20/// `"client"` for caller mistakes (4xx) and `"server"` for engine
21/// failures (5xx).
22#[derive(Debug, Serialize)]
23pub struct ErrorResponse {
24    pub error: ErrorBody,
25}
26
27#[derive(Debug, Serialize)]
28pub struct ErrorBody {
29    pub code: &'static str,
30    pub message: String,
31    pub category: &'static str,
32}
33
34impl ErrorResponse {
35    fn from_lora(err: &LoraError) -> Self {
36        Self {
37            error: ErrorBody {
38                code: err.code().as_str(),
39                message: err.public_message(),
40                category: err.category().as_str(),
41            },
42        }
43    }
44
45    /// Build an ad-hoc error response for cases that never reach the
46    /// engine (e.g. config-level argument validation in a handler).
47    pub(crate) fn from_parts(code: LoraErrorCode, message: impl Into<String>) -> Self {
48        Self {
49            error: ErrorBody {
50                code: code.as_str(),
51                message: message.into(),
52                category: code.category().as_str(),
53            },
54        }
55    }
56}
57
58pub(crate) fn json_rejection_error(rejection: JsonRejection) -> LoraError {
59    LoraError::new(
60        LoraErrorCode::InvalidParams,
61        format!("invalid JSON request body: {}", rejection.body_text()),
62    )
63}
64
65/// Map a [`LoraError`] to its HTTP status code.
66///
67/// Server-category errors collapse to 500, with one refinement:
68/// `WalPoisoned` / `Connection` → 503 because the engine cannot accept
69/// work until recovery or the backing handle becomes available again.
70///
71/// Client-category errors collapse to 400, with refinements that match
72/// standard HTTP semantics:
73/// * `Timeout` → 408 (cooperative-deadline expired)
74/// * `NotFound` → 404 (named entity does not exist)
75/// * `InvalidParams` / `InvalidVector` → 422 (well-formed request,
76///   semantically invalid value)
77/// * constraint/transaction failures → 409 (action conflicts with current state)
78fn status_for(err: &LoraError) -> StatusCode {
79    match err.code() {
80        // Server-category
81        LoraErrorCode::WalPoisoned | LoraErrorCode::Connection => StatusCode::SERVICE_UNAVAILABLE,
82        LoraErrorCode::Io
83        | LoraErrorCode::WalCorruption
84        | LoraErrorCode::SnapshotCodec
85        | LoraErrorCode::SnapshotCrypto
86        | LoraErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR,
87        // Client-category
88        LoraErrorCode::Timeout => StatusCode::REQUEST_TIMEOUT,
89        LoraErrorCode::NotFound => StatusCode::NOT_FOUND,
90        LoraErrorCode::ChangesTruncated => StatusCode::GONE,
91        LoraErrorCode::InvalidParams | LoraErrorCode::InvalidVector | LoraErrorCode::Validation => {
92            StatusCode::UNPROCESSABLE_ENTITY
93        }
94        LoraErrorCode::ConstraintViolation
95        | LoraErrorCode::Locked
96        | LoraErrorCode::ChangesLagged
97        | LoraErrorCode::UniqueConstraint
98        | LoraErrorCode::NotNullConstraint
99        | LoraErrorCode::ForeignKeyViolation
100        | LoraErrorCode::TransactionFailure => StatusCode::CONFLICT,
101        LoraErrorCode::Parse
102        | LoraErrorCode::Semantic
103        | LoraErrorCode::ReadOnlyViolation
104        | LoraErrorCode::DatabaseName
105        | LoraErrorCode::Config => StatusCode::BAD_REQUEST,
106    }
107}
108
109pub(crate) fn lora_error_response(err: impl Into<LoraError>) -> Response {
110    let lora = err.into();
111    let status = status_for(&lora);
112    match lora.category() {
113        LoraErrorCategory::Client => {
114            tracing::warn!(
115                code = lora.code().as_str(),
116                category = lora.category().as_str(),
117                public_message = %lora.public_message(),
118                diagnostic = %lora.debug_context(),
119                "database request failed"
120            );
121        }
122        LoraErrorCategory::Server => {
123            tracing::error!(
124                code = lora.code().as_str(),
125                category = lora.category().as_str(),
126                public_message = %lora.public_message(),
127                diagnostic = %lora.debug_context(),
128                "database request failed"
129            );
130        }
131    }
132    (status, Json(ErrorResponse::from_lora(&lora))).into_response()
133}