Skip to main content

quiver_server/
error.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The server error type and its mapping to HTTP (RFC-9457) and gRPC statuses
3//! (ADR-0017). Client messages are sanitized — internal details are logged, not
4//! returned.
5
6use axum::Json;
7use axum::http::header::CONTENT_TYPE;
8use axum::http::{HeaderValue, StatusCode};
9use axum::response::{IntoResponse, Response};
10use quiver_core::CoreError;
11use quiver_embed::Error as EngineError;
12use serde_json::json;
13use thiserror::Error;
14
15/// An error from the server or the engine beneath it.
16#[derive(Debug, Error)]
17#[non_exhaustive]
18pub enum Error {
19    /// An error from the embeddable engine.
20    #[error(transparent)]
21    Engine(#[from] EngineError),
22    /// The authenticated caller's API-key scope does not permit the operation
23    /// (RBAC, ADR-0011). The message is generic so it leaks no resource names.
24    #[error("{0}")]
25    Forbidden(String),
26    /// The request exceeds a configured cost limit or is otherwise malformed at
27    /// the server edge (ADR-0040). The message names the offending field, its
28    /// value, and the cap. Returned as HTTP 400 / gRPC `InvalidArgument`.
29    #[error("{0}")]
30    BadRequest(String),
31    /// Invalid or insecure configuration.
32    #[error("configuration error: {0}")]
33    Config(String),
34    /// A network or filesystem I/O error.
35    #[error("i/o error: {0}")]
36    Io(#[from] std::io::Error),
37    /// An unexpected internal failure (lock poisoned, task panicked, …).
38    #[error("internal error: {0}")]
39    Internal(String),
40    /// A configured upstream provider (server-side embedding or reranking,
41    /// ADR-0047) failed or returned a malformed response. Returned as HTTP 502 /
42    /// gRPC `Unavailable`. The message carries no secrets (only env-var *names*
43    /// and provider transport/parse detail), so it is shown to the client.
44    #[error("{0}")]
45    Upstream(String),
46    /// This node received a write but is not its shard's Raft leader (ADR-0067).
47    /// Carries the current leader's base URL when the group knows it, so a cluster
48    /// router (or client) redirects the write to the leader — the same
49    /// self-correcting data-path pattern as the cluster's "not my range" redirect.
50    /// Returned as HTTP 421 Misdirected Request / gRPC `Unavailable` (retry
51    /// elsewhere). The detail carries only a URL, so it is client-safe.
52    #[error("not the raft leader; leader: {leader:?}")]
53    NotLeader {
54        /// The current leader's gRPC base URL, if known.
55        leader: Option<String>,
56    },
57}
58
59impl Error {
60    // Map to an HTTP status and the equivalent gRPC code.
61    fn category(&self) -> (StatusCode, tonic::Code) {
62        match self {
63            Error::Engine(EngineError::CollectionNotFound(_))
64            | Error::Engine(EngineError::Core(CoreError::NotFound(_))) => {
65                (StatusCode::NOT_FOUND, tonic::Code::NotFound)
66            }
67            Error::Engine(EngineError::Core(CoreError::AlreadyExists(_))) => {
68                (StatusCode::CONFLICT, tonic::Code::AlreadyExists)
69            }
70            Error::Forbidden(_) => (StatusCode::FORBIDDEN, tonic::Code::PermissionDenied),
71            Error::BadRequest(_) => (StatusCode::BAD_REQUEST, tonic::Code::InvalidArgument),
72            Error::Upstream(_) => (StatusCode::BAD_GATEWAY, tonic::Code::Unavailable),
73            Error::NotLeader { .. } => (StatusCode::MISDIRECTED_REQUEST, tonic::Code::Unavailable),
74            Error::Engine(EngineError::Core(CoreError::InvalidArgument(_)))
75            | Error::Engine(EngineError::Index(_))
76            | Error::Engine(EngineError::Unsupported(_))
77            | Error::Engine(EngineError::Json(_)) => {
78                (StatusCode::BAD_REQUEST, tonic::Code::InvalidArgument)
79            }
80            _ => (StatusCode::INTERNAL_SERVER_ERROR, tonic::Code::Internal),
81        }
82    }
83
84    // A client-safe message: the detail for 4xx, a generic line for 5xx.
85    fn client_message(&self) -> String {
86        let (status, _) = self.category();
87        // 5xx detail is sanitized, except an upstream-provider failure whose
88        // message is client-safe and actionable (no secrets — names only).
89        if status.is_server_error() && !matches!(self, Error::Upstream(_)) {
90            "internal error".to_owned()
91        } else {
92            self.to_string()
93        }
94    }
95
96    /// Convert to a gRPC [`tonic::Status`], logging server-side faults.
97    pub(crate) fn to_status(&self) -> tonic::Status {
98        let (status, code) = self.category();
99        if status.is_server_error() {
100            tracing::error!(error = %self, "request failed");
101        }
102        tonic::Status::new(code, self.client_message())
103    }
104}
105
106impl IntoResponse for Error {
107    fn into_response(self) -> Response {
108        let (status, _) = self.category();
109        if status.is_server_error() {
110            tracing::error!(error = %self, "request failed");
111        }
112        let body = json!({
113            "type": "about:blank",
114            "title": status.canonical_reason().unwrap_or("Error"),
115            "status": status.as_u16(),
116            "detail": self.client_message(),
117        });
118        let mut response = (status, Json(body)).into_response();
119        response.headers_mut().insert(
120            CONTENT_TYPE,
121            HeaderValue::from_static("application/problem+json"),
122        );
123        response
124    }
125}