Skip to main content

web_faith/
error.rs

1//! Errors.
2
3use std::{
4	error::Error,
5	fmt::{Debug, Display},
6};
7
8#[cfg(feature = "unstable-internals")]
9use strum::EnumIter;
10#[cfg(feature = "unstable-internals")]
11use strum::IntoEnumIterator;
12
13/// The kind of a [`FaithError`].
14///
15/// Match on the kind; the message is for humans and may change.
16#[cfg_attr(feature = "unstable-internals", derive(EnumIter))]
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub enum FaithErrorKind {
19	/// The request was aborted by its caller.
20	Aborted,
21	/// An IP address or port in the options did not parse.
22	AddressParse,
23	/// The response body stream failed partway through.
24	BodyStream,
25	/// The agent has been closed.
26	Closed,
27	/// The agent options were invalid.
28	Config,
29	/// The response body ran past the `Content-Length` it advertised.
30	ContentLengthOverrun,
31	/// The destination file already exists and overwriting was not asked for.
32	FileExists,
33	/// The destination file could not be written.
34	FileWrite,
35	/// The body did not match the `integrity` digests.
36	IntegrityMismatch,
37	/// The `compress` option did not name a coding.
38	InvalidCompression,
39	/// A header name or value was not valid.
40	InvalidHeader,
41	/// The `integrity` value did not parse.
42	InvalidIntegrity,
43	/// The method was not a valid HTTP method.
44	InvalidMethod,
45	/// The destination did not name a local path.
46	InvalidPath,
47	/// The URL did not parse.
48	InvalidUrl,
49	/// The response body was not valid JSON.
50	JsonParse,
51	/// A `QUERY` request carried a body with no `Content-Type` to describe it.
52	MissingContentType,
53	/// The request failed on the network.
54	Network,
55	/// A client certificate or key was not valid PEM.
56	PemParse,
57	/// A redirect was refused, per the `error` redirect policy.
58	Redirect,
59	/// The body has already been read, or handed out as a stream.
60	ResponseAlreadyDisturbed,
61	/// The response cannot carry a body.
62	ResponseBodyNull,
63	/// The request outlived its timeout.
64	Timeout,
65}
66
67impl FaithErrorKind {
68	/// The name of this kind, as the JS bindings report it in an error's `code`.
69	#[cfg(feature = "unstable-internals")]
70	#[cfg_attr(docsrs, doc(cfg(feature = "unstable-internals")))]
71	pub fn code(self) -> String {
72		format!("{self:?}")
73	}
74
75	pub(crate) fn default_message(self) -> &'static str {
76		match self {
77			Self::Aborted => "the request was aborted",
78			Self::AddressParse => "invalid IP address and/or port",
79			Self::BodyStream => "internal response body stream copy error",
80			Self::Closed => "the agent has been closed",
81			Self::Config => "invalid agent configuration",
82			Self::ContentLengthOverrun => "response body exceeded the advertised Content-Length",
83			Self::FileExists => "the destination file already exists",
84			Self::FileWrite => "could not write the destination file",
85			Self::IntegrityMismatch => "resource integrity check failed",
86			Self::InvalidCompression => "invalid request body compression",
87			Self::InvalidHeader => "invalid header name or value",
88			Self::InvalidIntegrity => "invalid integrity value",
89			Self::InvalidMethod => "invalid HTTP method",
90			Self::InvalidPath => "destination does not name a local path",
91			Self::InvalidUrl => "invalid URL",
92			Self::JsonParse => "invalid json in response body",
93			Self::MissingContentType => "a QUERY request with a body requires a Content-Type",
94			Self::Network => "network error",
95			Self::PemParse => "invalid client certificate or key",
96			Self::Redirect => "got a redirect",
97			Self::ResponseAlreadyDisturbed => "response body already disturbed",
98			Self::ResponseBodyNull => "response cannot carry a body to write",
99			Self::Timeout => "timed out",
100		}
101	}
102}
103
104/// Every error code the library reports, in declaration order.
105#[cfg(feature = "unstable-internals")]
106pub fn error_codes() -> Vec<String> {
107	FaithErrorKind::iter().map(FaithErrorKind::code).collect()
108}
109
110/// An error from any layer of the client.
111///
112/// The [`Display`] output leads with the kind and carries the detail, if any.
113#[derive(Debug, Clone)]
114pub struct FaithError {
115	kind: FaithErrorKind,
116	/// Detail beyond what the kind says on its own.
117	message: Option<String>,
118}
119
120impl FaithError {
121	/// An error of `kind`, carrying detail beyond its default message.
122	///
123	/// [`From<FaithErrorKind>`](FaithError::from) makes one with no detail of its own.
124	pub fn new(kind: FaithErrorKind, message: impl Into<String>) -> Self {
125		Self {
126			kind,
127			message: Some(message.into()),
128		}
129	}
130
131	/// What went wrong.
132	pub fn kind(&self) -> FaithErrorKind {
133		self.kind
134	}
135}
136
137impl From<FaithErrorKind> for FaithError {
138	fn from(kind: FaithErrorKind) -> Self {
139		Self {
140			kind,
141			message: None,
142		}
143	}
144}
145
146/// Dig a [`FaithError`] back out of an error chain, if one is in there.
147///
148/// The `error` redirect policy refuses a redirect by handing reqwest a [`FaithError`], which comes
149/// back to us wrapped in an error of reqwest's own, so the kind we chose has to be recovered from
150/// the source chain to survive as a `code`. Redirect failures reqwest raises on its own account
151/// (exhausting the hop limit, an https-only downgrade) carry no [`FaithError`] and so fall through
152/// to the generic mapping, which tells the two apart.
153fn faith_kind_in_chain(err: &(dyn Error + 'static)) -> Option<FaithErrorKind> {
154	let mut source = err.source();
155	while let Some(e) = source {
156		if let Some(faith) = e.downcast_ref::<FaithError>() {
157			return Some(faith.kind());
158		}
159		source = e.source();
160	}
161
162	None
163}
164
165/// A conversion that cannot fail still has to satisfy the bound on a target, and this is how it
166/// does: there is no value to convert.
167impl From<std::convert::Infallible> for FaithError {
168	fn from(never: std::convert::Infallible) -> Self {
169		match never {}
170	}
171}
172
173impl From<reqwest::Error> for FaithError {
174	fn from(err: reqwest::Error) -> Self {
175		// Always include full error chain for debugging
176		let mut msg = format!("{err:?}");
177		let mut source = err.source();
178		while let Some(e) = source {
179			msg.push_str(&format!(" -> {e:?}"));
180			source = e.source();
181		}
182
183		if err.is_timeout() {
184			return FaithError::new(FaithErrorKind::Timeout, msg);
185		}
186
187		// A redirect the agent's own policy refused carries the kind we handed reqwest; one reqwest
188		// raised on its own account stays a plain network error.
189		let kind = err
190			.is_redirect()
191			.then(|| faith_kind_in_chain(&err))
192			.flatten()
193			.unwrap_or(FaithErrorKind::Network);
194
195		FaithError::new(kind, msg)
196	}
197}
198
199impl From<reqwest_middleware::Error> for FaithError {
200	fn from(err: reqwest_middleware::Error) -> Self {
201		match err {
202			reqwest_middleware::Error::Middleware(err) => {
203				FaithError::new(FaithErrorKind::Network, err.to_string())
204			}
205			reqwest_middleware::Error::Reqwest(err) => err.into(),
206		}
207	}
208}
209
210impl Error for FaithError {}
211
212impl Display for FaithError {
213	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
214		write!(
215			f,
216			"{:?}: {}",
217			self.kind,
218			self.message
219				.as_deref()
220				.unwrap_or_else(|| self.kind.default_message())
221		)
222	}
223}
224
225#[cfg(all(test, feature = "unstable-internals"))]
226mod tests {
227	use super::*;
228
229	#[test]
230	fn every_code_is_distinct_and_named() {
231		let codes = error_codes();
232		let unique: std::collections::BTreeSet<_> = codes.iter().collect();
233		assert_eq!(unique.len(), codes.len(), "two kinds report the same code");
234		assert!(codes.iter().all(|code| !code.is_empty()));
235	}
236
237	#[test]
238	fn a_message_is_prefixed_with_the_code_it_reports() {
239		for kind in FaithErrorKind::iter() {
240			let code = kind.code();
241			let rendered = FaithError::from(kind).to_string();
242			assert!(
243				rendered.starts_with(&format!("{code}: ")),
244				"{rendered} does not lead with {code}"
245			);
246		}
247	}
248
249	#[test]
250	fn a_kind_without_a_message_falls_back_to_its_own() {
251		let err = FaithError::from(FaithErrorKind::Closed);
252		assert_eq!(err.to_string(), "Closed: the agent has been closed");
253
254		let err = FaithError::new(FaithErrorKind::Closed, "gone");
255		assert_eq!(err.to_string(), "Closed: gone");
256	}
257}