Skip to main content

vane_core/
error.rs

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