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
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
//! Typed failures for the search subsystem.
use autumn_web::AutumnError;
/// Result alias used throughout `autumn-search`.
pub type SearchResult<T> = Result<T, SearchError>;
/// Everything that can go wrong indexing or querying.
///
/// Deliberately typed rather than stringly: the difference between "this index
/// is not registered" (a wiring bug, a 500) and "this query has no embedder"
/// (a configuration gap, a 500) is the difference between two very different
/// operator responses. Nothing here ever degrades into an unfiltered read.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SearchError {
/// No index with this name is registered on the client.
#[error(
"search index {0:?} is not registered; add it with `SearchPlugin::index::<Model>()` \
(the model must be `#[model]` + `#[searchable]` with an i64 primary key)"
)]
UnknownIndex(String),
/// The index definition failed [`autumn_web::search::IndexDefinition::validate`].
#[error("invalid search index definition: {0}")]
InvalidIndex(String),
/// A vector query hit an index with no `#[searchable(embed)]` field.
#[error(
"search index {index:?} has no embedded field; add `#[searchable(embed)]` to the field \
you want embedded to enable vector search"
)]
VectorUnsupported {
/// The index that was queried.
index: String,
},
/// A vector operation was attempted with no usable embedder installed.
#[error(
"no embedding provider is configured; install one with `SearchPlugin::embedder(...)` \
(the plugin ships no model or vendor by design)"
)]
EmbedderUnavailable,
/// The embedder itself failed.
#[error("embedding failed: {0}")]
Embedding(String),
/// A vector's length disagrees with what the index holds.
#[error("embedding has {actual} dimensions, expected {expected}")]
DimensionMismatch {
/// Dimensionality the index was built with.
expected: usize,
/// Dimensionality of the offending vector.
actual: usize,
},
/// A tenant-scoped index was queried with no tenant context in scope.
///
/// Refused rather than run unscoped: the model carries a `tenant_id`
/// column, so an unscoped query would return every tenant's records — and
/// compute `total_elements` across all of them. This mirrors
/// `#[repository(tenant_scoped)]`, which errors for the same reason.
#[error(
"search index {index:?} is tenant-scoped but no tenant context is established; \
run the query inside a tenant scope (the `TenancyLayer`, or \
`autumn_web::tenancy::with_tenant(...)` for a job or background task)"
)]
TenantContextMissing {
/// The index that was queried.
index: String,
},
/// An authorization-aware entry point was used with no visibility hook.
#[error(
"no `SearchVisibility` is registered; install one with `SearchPlugin::visibility(...)` \
— an authorization-aware search must never fall back to an unfiltered one"
)]
VisibilityUnavailable,
/// A reindex/backfill needs a [`crate::DocumentSource`] and none is installed.
#[error(
"no `DocumentSource` is installed; add one with `SearchPlugin::source(...)` \
(the Postgres source is installed automatically by `SearchPlugin::postgres()`)"
)]
SourceUnavailable,
/// The backend does not implement this operation.
#[error("search backend {backend:?} does not support {operation}")]
Unsupported {
/// Backend name.
backend: &'static str,
/// The unsupported operation.
operation: &'static str,
},
/// The backend failed (I/O, SQL, an engine error).
#[error("search backend error: {0}")]
Backend(String),
}
impl SearchError {
/// Wrap an arbitrary backend failure.
#[must_use]
pub fn backend(error: impl std::fmt::Display) -> Self {
Self::Backend(error.to_string())
}
/// Wrap an arbitrary embedding failure.
#[must_use]
pub fn embedding(error: impl std::fmt::Display) -> Self {
Self::Embedding(error.to_string())
}
}
impl From<AutumnError> for SearchError {
/// Carry an application error (typically from a repository call inside a
/// hydration loader or a custom [`crate::DocumentSource`]) into a
/// [`SearchError`].
///
/// Without this, `search_hydrated`'s loader — whose whole job is to call
/// `repo.find_all(...)` — could not use `?`, which is unavoidable
/// boilerplate on the single most common call site in the crate.
fn from(error: AutumnError) -> Self {
Self::Backend(error.to_string())
}
}
impl SearchError {
/// Convert into an [`AutumnError`] with a status that matches the cause.
///
/// A blanket `impl<E: std::error::Error> From<E> for AutumnError` already
/// exists in `autumn-web`, so `?` works without this — but that blanket
/// impl cannot tell a bad *query* from a broken *deployment*. Use this at
/// a handler boundary to get the distinction: a vector query against an
/// index with no embedded field is the caller's mistake (400), while a
/// missing embedder or an unreachable engine is the operator's (500).
#[must_use]
pub fn into_autumn_error(self) -> AutumnError {
let message = self.to_string();
match self {
// Caller-fixable: the query asked for something this index cannot
// answer.
Self::VectorUnsupported { .. } | Self::DimensionMismatch { .. } => {
AutumnError::bad_request_msg(message)
}
// Operator-fixable configuration gaps and genuine failures.
Self::UnknownIndex(_)
| Self::InvalidIndex(_)
| Self::EmbedderUnavailable
| Self::VisibilityUnavailable
| Self::SourceUnavailable
| Self::TenantContextMissing { .. }
| Self::Unsupported { .. }
| Self::Embedding(_)
| Self::Backend(_) => AutumnError::internal_server_error_msg(message),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_bad_query_maps_to_a_client_error_not_a_500() {
let error = SearchError::VectorUnsupported {
index: "articles".to_owned(),
}
.into_autumn_error();
assert_eq!(error.status().as_u16(), 400);
}
#[test]
fn a_wiring_gap_maps_to_a_server_error() {
let error = SearchError::UnknownIndex("articles".to_owned()).into_autumn_error();
assert_eq!(error.status().as_u16(), 500);
}
#[test]
fn a_missing_tenant_context_is_refused_rather_than_run_unscoped() {
let error = SearchError::TenantContextMissing {
index: "articles".to_owned(),
};
let message = error.to_string();
assert!(message.contains("tenant-scoped"), "{message}");
assert!(message.contains("tenant scope"), "{message}");
assert_eq!(error.into_autumn_error().status().as_u16(), 500);
}
#[test]
fn an_application_error_converts_so_a_hydration_loader_can_use_question_mark() {
let error: SearchError = AutumnError::internal_server_error_msg("row not found").into();
assert!(matches!(error, SearchError::Backend(ref m) if m.contains("row not found")));
}
#[test]
fn messages_name_the_fix() {
assert!(
SearchError::EmbedderUnavailable
.to_string()
.contains("SearchPlugin::embedder")
);
assert!(
SearchError::UnknownIndex("a".to_owned())
.to_string()
.contains("SearchPlugin::index")
);
}
}