Skip to main content

vane_core/
error.rs

1use std::borrow::Cow;
2
3pub const SERIALIZED_MESSAGE_CAP: usize = 4 * 1024;
4pub const SERIALIZED_CTX_CAP: usize = 1024;
5pub const SERIALIZED_CHAIN_MAX_ENTRIES: usize = 16;
6pub const SERIALIZED_CHAIN_ENTRY_CAP: usize = 1024;
7
8#[derive(thiserror::Error, Debug)]
9#[error("{kind}{}", .ctx.as_deref().map(|c| format!(": {c}")).unwrap_or_default())]
10pub struct Error {
11	pub kind: ErrorKind,
12	pub ctx: Option<Cow<'static, str>>,
13	#[source]
14	pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
15}
16
17#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
18pub enum ErrorKind {
19	#[error("i/o")]
20	Io,
21	#[error("protocol")]
22	Protocol,
23	#[error("upstream: {0}")]
24	Upstream(UpstreamReason),
25	#[error("middleware")]
26	Middleware,
27	#[error("compile")]
28	Compile,
29	#[error("timeout: {0}")]
30	Timeout(TimeoutKind),
31	#[error("canceled")]
32	Canceled,
33	#[error("resource: {0}")]
34	Resource(ResourceKind),
35	#[error("internal")]
36	Internal,
37}
38
39#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
40pub enum UpstreamReason {
41	#[error("unreachable")]
42	Unreachable,
43	#[error("reset mid-request")]
44	ResetMidRequest,
45	#[error("reset on idle pickup")]
46	ResetOnIdlePickup,
47	#[error("tls handshake failed")]
48	TlsHandshake,
49	#[error("dns resolution failed")]
50	DnsFailure,
51	#[error("refused by upstream")]
52	Refused,
53	#[error("gone")]
54	Gone,
55	#[error("malformed response")]
56	Malformed,
57}
58
59#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
60pub enum TimeoutKind {
61	#[error("connect")]
62	Connect,
63	#[error("read")]
64	Read,
65	#[error("total")]
66	Total,
67	#[error("idle")]
68	Idle,
69	#[error("handshake")]
70	Handshake,
71}
72
73#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
74pub enum ResourceKind {
75	#[error("connection pool exhausted")]
76	ConnectionPool,
77	#[error("wasm pool exhausted")]
78	WasmPool,
79	#[error("memory budget exceeded")]
80	Memory,
81	#[error("file descriptors exhausted")]
82	FdExhausted,
83}
84
85impl Error {
86	#[must_use]
87	pub const fn new(kind: ErrorKind) -> Self {
88		Self { kind, ctx: None, source: None }
89	}
90
91	#[must_use]
92	pub fn with_ctx(mut self, ctx: impl Into<Cow<'static, str>>) -> Self {
93		self.ctx = Some(ctx.into());
94		self
95	}
96
97	#[must_use]
98	pub fn with_source<E: Into<Box<dyn std::error::Error + Send + Sync>>>(mut self, e: E) -> Self {
99		self.source = Some(e.into());
100		self
101	}
102
103	#[must_use]
104	pub fn io(msg: impl Into<Cow<'static, str>>) -> Self {
105		Self::new(ErrorKind::Io).with_ctx(msg)
106	}
107
108	#[must_use]
109	pub fn protocol(msg: impl Into<Cow<'static, str>>) -> Self {
110		Self::new(ErrorKind::Protocol).with_ctx(msg)
111	}
112
113	#[must_use]
114	pub const fn upstream(reason: UpstreamReason) -> Self {
115		Self::new(ErrorKind::Upstream(reason))
116	}
117
118	#[must_use]
119	pub fn middleware(msg: impl Into<Cow<'static, str>>) -> Self {
120		Self::new(ErrorKind::Middleware).with_ctx(msg)
121	}
122
123	#[must_use]
124	pub fn compile(msg: impl Into<Cow<'static, str>>) -> Self {
125		Self::new(ErrorKind::Compile).with_ctx(msg)
126	}
127
128	#[must_use]
129	pub const fn timeout(kind: TimeoutKind) -> Self {
130		Self::new(ErrorKind::Timeout(kind))
131	}
132
133	#[must_use]
134	pub const fn canceled() -> Self {
135		Self::new(ErrorKind::Canceled)
136	}
137
138	#[must_use]
139	pub const fn resource(kind: ResourceKind) -> Self {
140		Self::new(ErrorKind::Resource(kind))
141	}
142
143	#[must_use]
144	pub fn internal(msg: impl Into<Cow<'static, str>>) -> Self {
145		Self::new(ErrorKind::Internal).with_ctx(msg)
146	}
147
148	#[must_use]
149	pub const fn kind(&self) -> &ErrorKind {
150		&self.kind
151	}
152
153	#[must_use]
154	pub fn ctx(&self) -> Option<&str> {
155		self.ctx.as_deref()
156	}
157
158	#[must_use]
159	pub const fn kind_label(&self) -> &'static str {
160		match &self.kind {
161			ErrorKind::Io => "io",
162			ErrorKind::Protocol => "protocol",
163			ErrorKind::Upstream(_) => "upstream",
164			ErrorKind::Middleware => "middleware",
165			ErrorKind::Compile => "compile",
166			ErrorKind::Timeout(_) => "timeout",
167			ErrorKind::Canceled => "canceled",
168			ErrorKind::Resource(_) => "resource",
169			ErrorKind::Internal => "internal",
170		}
171	}
172
173	#[must_use]
174	pub const fn reason_label(&self) -> Option<&'static str> {
175		match &self.kind {
176			ErrorKind::Upstream(r) => Some(match r {
177				UpstreamReason::Unreachable => "unreachable",
178				UpstreamReason::ResetMidRequest => "reset_mid_request",
179				UpstreamReason::ResetOnIdlePickup => "reset_idle_pickup",
180				UpstreamReason::TlsHandshake => "tls_handshake",
181				UpstreamReason::DnsFailure => "dns_failure",
182				UpstreamReason::Refused => "refused",
183				UpstreamReason::Gone => "gone",
184				UpstreamReason::Malformed => "malformed",
185			}),
186			ErrorKind::Timeout(t) => Some(match t {
187				TimeoutKind::Connect => "connect",
188				TimeoutKind::Read => "read",
189				TimeoutKind::Total => "total",
190				TimeoutKind::Idle => "idle",
191				TimeoutKind::Handshake => "handshake",
192			}),
193			ErrorKind::Resource(r) => Some(match r {
194				ResourceKind::ConnectionPool => "connection_pool",
195				ResourceKind::WasmPool => "wasm_pool",
196				ResourceKind::Memory => "memory",
197				ResourceKind::FdExhausted => "fd_exhausted",
198			}),
199			_ => None,
200		}
201	}
202
203	#[must_use]
204	pub const fn is_retryable(&self) -> bool {
205		match &self.kind {
206			ErrorKind::Upstream(r) => matches!(
207				r,
208				UpstreamReason::Unreachable
209					| UpstreamReason::ResetOnIdlePickup
210					| UpstreamReason::DnsFailure
211					| UpstreamReason::Refused
212					| UpstreamReason::Gone
213			),
214			ErrorKind::Timeout(TimeoutKind::Connect)
215			| ErrorKind::Resource(ResourceKind::ConnectionPool) => true,
216			_ => false,
217		}
218	}
219
220	#[must_use]
221	pub const fn http_status(&self) -> u16 {
222		match &self.kind {
223			ErrorKind::Protocol => 400,
224			ErrorKind::Upstream(_) => 502,
225			ErrorKind::Timeout(_) => 504,
226			ErrorKind::Resource(_) => 503,
227			ErrorKind::Canceled => 499,
228			ErrorKind::Middleware | ErrorKind::Compile | ErrorKind::Internal | ErrorKind::Io => 500,
229		}
230	}
231
232	#[must_use]
233	pub fn source_chain(&self) -> Vec<String> {
234		let mut out = Vec::new();
235		let mut cur: &dyn std::error::Error = self;
236		while let Some(src) = cur.source() {
237			out.push(src.to_string());
238			cur = src;
239		}
240		out
241	}
242}
243
244fn from_source<E>(kind: ErrorKind, e: E) -> Error
245where
246	E: std::error::Error + Send + Sync + 'static,
247{
248	Error { kind, ctx: None, source: Some(Box::new(e)) }
249}
250
251impl From<std::io::Error> for Error {
252	fn from(e: std::io::Error) -> Self {
253		from_source(ErrorKind::Io, e)
254	}
255}
256
257impl From<serde_json::Error> for Error {
258	fn from(e: serde_json::Error) -> Self {
259		from_source(ErrorKind::Compile, e)
260	}
261}
262
263impl From<fancy_regex::Error> for Error {
264	fn from(e: fancy_regex::Error) -> Self {
265		from_source(ErrorKind::Compile, e)
266	}
267}
268
269impl From<ipnet::AddrParseError> for Error {
270	fn from(e: ipnet::AddrParseError) -> Self {
271		from_source(ErrorKind::Compile, e)
272	}
273}
274
275// `Elapsed` carries no discriminator for which timeout tripped, so the
276// conversion picks the most general bucket; sites that know the specific
277// phase should build the error explicitly via `Error::timeout(kind)`.
278impl From<tokio::time::error::Elapsed> for Error {
279	fn from(e: tokio::time::error::Elapsed) -> Self {
280		from_source(ErrorKind::Timeout(TimeoutKind::Total), e)
281	}
282}
283
284// `From<hyper::Error>` / `h3::Error` / `rustls::Error` /
285// `hickory_resolver::ResolveError` deliberately not implemented here:
286// vane-core is backend-agnostic, and adding those impls (orphan rules
287// require they live next to the local type) would force every transport
288// crate into core's dep graph. Engine code constructs upstream errors
289// explicitly via `Error::upstream(...).with_source(e)` so the
290// `ErrorKind` / `UpstreamReason` is chosen at the call site rather
291// than baked into a blanket `From` impl.
292
293#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)]
294pub struct SerializedError {
295	pub kind: String,
296	pub reason: Option<String>,
297	pub message: String,
298	pub ctx: Option<String>,
299	pub source_chain: Vec<String>,
300	pub http_status: u16,
301	pub retryable: bool,
302}
303
304impl From<&Error> for SerializedError {
305	fn from(e: &Error) -> Self {
306		Self {
307			kind: e.kind_label().to_owned(),
308			reason: e.reason_label().map(ToOwned::to_owned),
309			message: cap_bytes(e.to_string(), SERIALIZED_MESSAGE_CAP),
310			ctx: e.ctx.as_deref().map(|c| cap_bytes(c.to_owned(), SERIALIZED_CTX_CAP)),
311			source_chain: cap_chain(e.source_chain()),
312			http_status: e.http_status(),
313			retryable: e.is_retryable(),
314		}
315	}
316}
317
318const TRUNC_SUFFIX: &str = "… [truncated]";
319
320fn cap_bytes(s: String, cap: usize) -> String {
321	if s.len() <= cap {
322		return s;
323	}
324	let budget = cap.saturating_sub(TRUNC_SUFFIX.len());
325	let mut end = budget.min(s.len());
326	while end > 0 && !s.is_char_boundary(end) {
327		end -= 1;
328	}
329	let mut out = String::with_capacity(end + TRUNC_SUFFIX.len());
330	out.push_str(&s[..end]);
331	out.push_str(TRUNC_SUFFIX);
332	out
333}
334
335fn cap_chain(chain: Vec<String>) -> Vec<String> {
336	if chain.len() <= SERIALIZED_CHAIN_MAX_ENTRIES {
337		return chain.into_iter().map(|s| cap_bytes(s, SERIALIZED_CHAIN_ENTRY_CAP)).collect();
338	}
339	let keep = SERIALIZED_CHAIN_MAX_ENTRIES - 1;
340	let dropped = chain.len() - keep;
341	let mut out: Vec<String> =
342		chain.into_iter().take(keep).map(|s| cap_bytes(s, SERIALIZED_CHAIN_ENTRY_CAP)).collect();
343	out.push(format!("… [{dropped} more]"));
344	out
345}