Skip to main content

bezant_server/
error.rs

1//! HTTP error type — maps `bezant::Error` into JSON responses.
2
3use axum::http::StatusCode;
4use axum::response::{IntoResponse, Response};
5use axum::Json;
6use serde::Serialize;
7
8/// A JSON error envelope returned on failure.
9#[derive(Debug, Serialize)]
10pub struct ErrorBody {
11    /// Machine-readable error code.
12    pub code: &'static str,
13    /// Human-readable message.
14    pub message: String,
15}
16
17/// Wraps [`bezant::Error`] so axum handlers can bubble errors with `?`.
18///
19/// The inner error is deliberately private — the HTTP surface exposes
20/// status + code + message, not the whole typed variant. Construct via
21/// `From<bezant::Error>` (i.e. the `?` operator) rather than a literal.
22#[derive(Debug)]
23pub struct AppError(bezant::Error);
24
25impl AppError {
26    /// Borrow the wrapped error. Prefer matching on the HTTP response
27    /// instead; this exists for logging / tracing use cases.
28    #[must_use]
29    pub fn inner(&self) -> &bezant::Error {
30        &self.0
31    }
32}
33
34impl From<bezant::Error> for AppError {
35    fn from(value: bezant::Error) -> Self {
36        Self(value)
37    }
38}
39
40impl From<anyhow::Error> for AppError {
41    fn from(value: anyhow::Error) -> Self {
42        Self(bezant::Error::from(value))
43    }
44}
45
46impl IntoResponse for AppError {
47    fn into_response(self) -> Response {
48        let (status, code) = map_status(&self.0);
49
50        // Log every mapped failure at the boundary so production debuggability
51        // doesn't depend on every handler manually emitting a span event.
52        // 5xx are upstream/internal — `error!`. 4xx are caller-input — `warn!`.
53        // Avoids logging the full debug repr to minimise log volume + redaction
54        // surface (the typed `Display` impl is already redaction-safe).
55        if status.is_server_error() {
56            tracing::error!(
57                code,
58                status = %status,
59                error = %self.0,
60                "request failed (5xx)"
61            );
62        } else {
63            tracing::warn!(
64                code,
65                status = %status,
66                error = %self.0,
67                "request failed (4xx)"
68            );
69        }
70
71        let body = ErrorBody {
72            code,
73            message: self.0.to_string(),
74        };
75        (status, Json(body)).into_response()
76    }
77}
78
79/// Map a `bezant::Error` variant to its HTTP status + machine code.
80///
81/// Branching by variant rather than reflection so the mapping is
82/// auditable + the compiler enforces exhaustiveness via the
83/// `#[non_exhaustive]` catch-all (`_`).
84fn map_status(err: &bezant::Error) -> (StatusCode, &'static str) {
85    use bezant::Error as E;
86    match err {
87        E::InvalidBaseUrl(_) => (StatusCode::BAD_REQUEST, "invalid_base_url"),
88        E::UrlNotABase { .. } => (StatusCode::BAD_REQUEST, "url_not_a_base"),
89        E::Http(e) => {
90            // Branch on reqwest error shape so 504/503 are real signals
91            // for upstream-overload retry loops (HAProxy / k8s probes /
92            // alerting). `is_request()` covers DNS / TLS etc.
93            if e.is_timeout() {
94                (StatusCode::GATEWAY_TIMEOUT, "upstream_timeout")
95            } else if e.is_connect() {
96                (StatusCode::SERVICE_UNAVAILABLE, "upstream_unreachable")
97            } else {
98                (StatusCode::BAD_GATEWAY, "upstream_http_error")
99            }
100        }
101        E::UpstreamStatus { status, .. } => {
102            // 5xx and 429 propagate as 5xx; 4xx as the same 4xx the upstream
103            // returned (so callers can distinguish "you sent garbage" from
104            // "upstream is broken"). Other status codes default to 502.
105            let s = StatusCode::from_u16(*status).unwrap_or(StatusCode::BAD_GATEWAY);
106            if s.is_server_error() || s.as_u16() == 429 {
107                (s, "upstream_status")
108            } else if s.is_client_error() {
109                (s, "upstream_client_error")
110            } else {
111                (StatusCode::BAD_GATEWAY, "upstream_status")
112            }
113        }
114        E::Unknown { .. } => (StatusCode::BAD_GATEWAY, "upstream_unknown_variant"),
115        E::Decode { .. } => (StatusCode::BAD_GATEWAY, "upstream_decode_error"),
116        E::Api(_) => (StatusCode::BAD_GATEWAY, "upstream_api_error"),
117        E::BadRequest(_) => (StatusCode::BAD_REQUEST, "bad_request"),
118        E::MissingQuery { .. } => (StatusCode::BAD_REQUEST, "missing_query_param"),
119        E::Header { .. } => (StatusCode::BAD_REQUEST, "invalid_header_value"),
120        E::SymbolNotFound { .. } => (StatusCode::NOT_FOUND, "symbol_not_found"),
121        E::BadConid { .. } => (StatusCode::BAD_GATEWAY, "upstream_bad_conid"),
122        E::WsHandshake { .. } => (StatusCode::BAD_GATEWAY, "ws_handshake_failed"),
123        E::WsTransport { .. } => (StatusCode::BAD_GATEWAY, "ws_transport_failed"),
124        E::WsProtocol(_) => (StatusCode::BAD_GATEWAY, "ws_protocol_error"),
125        E::ResponseBuild(_) => (StatusCode::INTERNAL_SERVER_ERROR, "response_build"),
126        E::NotAuthenticated => (StatusCode::UNAUTHORIZED, "not_authenticated"),
127        E::NoSession => (StatusCode::SERVICE_UNAVAILABLE, "no_session"),
128        E::Other(_) => (StatusCode::INTERNAL_SERVER_ERROR, "internal"),
129        // `bezant::Error` is `#[non_exhaustive]` — future-proof the match.
130        _ => {
131            tracing::error!(error = ?err, "unmapped bezant::Error variant");
132            (StatusCode::INTERNAL_SERVER_ERROR, "internal")
133        }
134    }
135}