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}
47
48impl Error {
49    // Map to an HTTP status and the equivalent gRPC code.
50    fn category(&self) -> (StatusCode, tonic::Code) {
51        match self {
52            Error::Engine(EngineError::CollectionNotFound(_))
53            | Error::Engine(EngineError::Core(CoreError::NotFound(_))) => {
54                (StatusCode::NOT_FOUND, tonic::Code::NotFound)
55            }
56            Error::Engine(EngineError::Core(CoreError::AlreadyExists(_))) => {
57                (StatusCode::CONFLICT, tonic::Code::AlreadyExists)
58            }
59            Error::Forbidden(_) => (StatusCode::FORBIDDEN, tonic::Code::PermissionDenied),
60            Error::BadRequest(_) => (StatusCode::BAD_REQUEST, tonic::Code::InvalidArgument),
61            Error::Upstream(_) => (StatusCode::BAD_GATEWAY, tonic::Code::Unavailable),
62            Error::Engine(EngineError::Core(CoreError::InvalidArgument(_)))
63            | Error::Engine(EngineError::Index(_))
64            | Error::Engine(EngineError::Unsupported(_))
65            | Error::Engine(EngineError::Json(_)) => {
66                (StatusCode::BAD_REQUEST, tonic::Code::InvalidArgument)
67            }
68            _ => (StatusCode::INTERNAL_SERVER_ERROR, tonic::Code::Internal),
69        }
70    }
71
72    // A client-safe message: the detail for 4xx, a generic line for 5xx.
73    fn client_message(&self) -> String {
74        let (status, _) = self.category();
75        // 5xx detail is sanitized, except an upstream-provider failure whose
76        // message is client-safe and actionable (no secrets — names only).
77        if status.is_server_error() && !matches!(self, Error::Upstream(_)) {
78            "internal error".to_owned()
79        } else {
80            self.to_string()
81        }
82    }
83
84    /// Convert to a gRPC [`tonic::Status`], logging server-side faults.
85    pub(crate) fn to_status(&self) -> tonic::Status {
86        let (status, code) = self.category();
87        if status.is_server_error() {
88            tracing::error!(error = %self, "request failed");
89        }
90        tonic::Status::new(code, self.client_message())
91    }
92}
93
94impl IntoResponse for Error {
95    fn into_response(self) -> Response {
96        let (status, _) = self.category();
97        if status.is_server_error() {
98            tracing::error!(error = %self, "request failed");
99        }
100        let body = json!({
101            "type": "about:blank",
102            "title": status.canonical_reason().unwrap_or("Error"),
103            "status": status.as_u16(),
104            "detail": self.client_message(),
105        });
106        let mut response = (status, Json(body)).into_response();
107        response.headers_mut().insert(
108            CONTENT_TYPE,
109            HeaderValue::from_static("application/problem+json"),
110        );
111        response
112    }
113}