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