Skip to main content

lora_server/app/
errors.rs

1use axum::{
2    http::StatusCode,
3    response::{IntoResponse, Response},
4    Json,
5};
6use lora_database::{LoraError, LoraErrorCode};
7use serde::Serialize;
8
9/// Structured error body returned by every fallible HTTP endpoint.
10///
11/// Wire shape:
12/// ```json
13/// { "error": { "code": "LORA_PARSE", "message": "...", "category": "client" } }
14/// ```
15///
16/// `code` is a stable wire string from the [`LoraErrorCode`] catalog and
17/// is the field bindings / dashboards / tests should match on. `message`
18/// is human-friendly and may be reworded between releases. `category` is
19/// `"client"` for caller mistakes (4xx) and `"server"` for engine
20/// failures (5xx).
21#[derive(Debug, Serialize)]
22pub struct ErrorResponse {
23    pub error: ErrorBody,
24}
25
26#[derive(Debug, Serialize)]
27pub struct ErrorBody {
28    pub code: &'static str,
29    pub message: String,
30    pub category: &'static str,
31}
32
33impl ErrorResponse {
34    fn from_lora(err: &LoraError) -> Self {
35        Self {
36            error: ErrorBody {
37                code: err.code().as_str(),
38                message: err.message().to_string(),
39                category: err.category().as_str(),
40            },
41        }
42    }
43
44    /// Build an ad-hoc error response for cases that never reach the
45    /// engine (e.g. config-level argument validation in a handler).
46    pub(crate) fn from_parts(code: LoraErrorCode, message: impl Into<String>) -> Self {
47        Self {
48            error: ErrorBody {
49                code: code.as_str(),
50                message: message.into(),
51                category: code.category().as_str(),
52            },
53        }
54    }
55}
56
57/// Map a [`LoraError`] to its HTTP status code.
58///
59/// Server-category errors collapse to 500, with one refinement:
60/// `WalPoisoned` → 503 because the engine cannot accept further writes
61/// until an operator restarts from snapshot + WAL.
62///
63/// Client-category errors collapse to 400, with refinements that match
64/// standard HTTP semantics:
65/// * `Timeout` → 408 (cooperative-deadline expired)
66/// * `NotFound` → 404 (named entity does not exist)
67/// * `InvalidParams` / `InvalidVector` → 422 (well-formed request,
68///   semantically invalid value)
69/// * `ConstraintViolation` → 409 (action conflicts with current state)
70fn status_for(err: &LoraError) -> StatusCode {
71    match err.code() {
72        // Server-category
73        LoraErrorCode::WalPoisoned => StatusCode::SERVICE_UNAVAILABLE,
74        LoraErrorCode::Io
75        | LoraErrorCode::WalCorruption
76        | LoraErrorCode::SnapshotCodec
77        | LoraErrorCode::SnapshotCrypto
78        | LoraErrorCode::Internal => StatusCode::INTERNAL_SERVER_ERROR,
79        // Client-category
80        LoraErrorCode::Timeout => StatusCode::REQUEST_TIMEOUT,
81        LoraErrorCode::NotFound => StatusCode::NOT_FOUND,
82        LoraErrorCode::InvalidParams | LoraErrorCode::InvalidVector => {
83            StatusCode::UNPROCESSABLE_ENTITY
84        }
85        LoraErrorCode::ConstraintViolation => StatusCode::CONFLICT,
86        LoraErrorCode::Parse
87        | LoraErrorCode::Semantic
88        | LoraErrorCode::ReadOnlyViolation
89        | LoraErrorCode::DatabaseName
90        | LoraErrorCode::Config => StatusCode::BAD_REQUEST,
91    }
92}
93
94pub(crate) fn lora_error_response(err: impl Into<LoraError>) -> Response {
95    let lora = err.into();
96    let status = status_for(&lora);
97    (status, Json(ErrorResponse::from_lora(&lora))).into_response()
98}