web-faith 1.1.0

A browser-shaped HTTP client
Documentation
//! Errors.

use std::{
	error::Error,
	fmt::{Debug, Display},
};

#[cfg(feature = "unstable-internals")]
use strum::EnumIter;
#[cfg(feature = "unstable-internals")]
use strum::IntoEnumIterator;

/// The kind of a [`FaithError`].
///
/// Match on the kind; the message is for humans and may change.
#[cfg_attr(feature = "unstable-internals", derive(EnumIter))]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FaithErrorKind {
	/// The request was aborted by its caller.
	Aborted,
	/// An IP address or port in the options did not parse.
	AddressParse,
	/// The response body stream failed partway through.
	BodyStream,
	/// The agent has been closed.
	Closed,
	/// The agent options were invalid.
	Config,
	/// The response body ran past the `Content-Length` it advertised.
	ContentLengthOverrun,
	/// The destination file already exists and overwriting was not asked for.
	FileExists,
	/// The destination file could not be written.
	FileWrite,
	/// The body did not match the `integrity` digests.
	IntegrityMismatch,
	/// The `compress` option did not name a coding.
	InvalidCompression,
	/// A header name or value was not valid.
	InvalidHeader,
	/// The `integrity` value did not parse.
	InvalidIntegrity,
	/// The method was not a valid HTTP method.
	InvalidMethod,
	/// The destination did not name a local path.
	InvalidPath,
	/// The URL did not parse.
	InvalidUrl,
	/// The response body was not valid JSON.
	JsonParse,
	/// A `QUERY` request carried a body with no `Content-Type` to describe it.
	MissingContentType,
	/// The request failed on the network.
	Network,
	/// A client certificate or key was not valid PEM.
	PemParse,
	/// A redirect was refused, per the `error` redirect policy.
	Redirect,
	/// The body has already been read, or handed out as a stream.
	ResponseAlreadyDisturbed,
	/// The response cannot carry a body.
	ResponseBodyNull,
	/// The request outlived its timeout.
	Timeout,
}

impl FaithErrorKind {
	/// The name of this kind, as the JS bindings report it in an error's `code`.
	#[cfg(feature = "unstable-internals")]
	#[cfg_attr(docsrs, doc(cfg(feature = "unstable-internals")))]
	pub fn code(self) -> String {
		format!("{self:?}")
	}

	pub(crate) fn default_message(self) -> &'static str {
		match self {
			Self::Aborted => "the request was aborted",
			Self::AddressParse => "invalid IP address and/or port",
			Self::BodyStream => "internal response body stream copy error",
			Self::Closed => "the agent has been closed",
			Self::Config => "invalid agent configuration",
			Self::ContentLengthOverrun => "response body exceeded the advertised Content-Length",
			Self::FileExists => "the destination file already exists",
			Self::FileWrite => "could not write the destination file",
			Self::IntegrityMismatch => "resource integrity check failed",
			Self::InvalidCompression => "invalid request body compression",
			Self::InvalidHeader => "invalid header name or value",
			Self::InvalidIntegrity => "invalid integrity value",
			Self::InvalidMethod => "invalid HTTP method",
			Self::InvalidPath => "destination does not name a local path",
			Self::InvalidUrl => "invalid URL",
			Self::JsonParse => "invalid json in response body",
			Self::MissingContentType => "a QUERY request with a body requires a Content-Type",
			Self::Network => "network error",
			Self::PemParse => "invalid client certificate or key",
			Self::Redirect => "got a redirect",
			Self::ResponseAlreadyDisturbed => "response body already disturbed",
			Self::ResponseBodyNull => "response cannot carry a body to write",
			Self::Timeout => "timed out",
		}
	}
}

/// Every error code the library reports, in declaration order.
#[cfg(feature = "unstable-internals")]
pub fn error_codes() -> Vec<String> {
	FaithErrorKind::iter().map(FaithErrorKind::code).collect()
}

/// An error from any layer of the client.
///
/// The [`Display`] output leads with the kind and carries the detail, if any.
#[derive(Debug, Clone)]
pub struct FaithError {
	kind: FaithErrorKind,
	/// Detail beyond what the kind says on its own.
	message: Option<String>,
}

impl FaithError {
	/// An error of `kind`, carrying detail beyond its default message.
	///
	/// [`From<FaithErrorKind>`](FaithError::from) makes one with no detail of its own.
	pub fn new(kind: FaithErrorKind, message: impl Into<String>) -> Self {
		Self {
			kind,
			message: Some(message.into()),
		}
	}

	/// What went wrong.
	pub fn kind(&self) -> FaithErrorKind {
		self.kind
	}
}

impl From<FaithErrorKind> for FaithError {
	fn from(kind: FaithErrorKind) -> Self {
		Self {
			kind,
			message: None,
		}
	}
}

/// Dig a [`FaithError`] back out of an error chain, if one is in there.
///
/// The `error` redirect policy refuses a redirect by handing reqwest a [`FaithError`], which comes
/// back to us wrapped in an error of reqwest's own, so the kind we chose has to be recovered from
/// the source chain to survive as a `code`. Redirect failures reqwest raises on its own account
/// (exhausting the hop limit, an https-only downgrade) carry no [`FaithError`] and so fall through
/// to the generic mapping, which tells the two apart.
fn faith_kind_in_chain(err: &(dyn Error + 'static)) -> Option<FaithErrorKind> {
	let mut source = err.source();
	while let Some(e) = source {
		if let Some(faith) = e.downcast_ref::<FaithError>() {
			return Some(faith.kind());
		}
		source = e.source();
	}

	None
}

/// A conversion that cannot fail still has to satisfy the bound on a target, and this is how it
/// does: there is no value to convert.
impl From<std::convert::Infallible> for FaithError {
	fn from(never: std::convert::Infallible) -> Self {
		match never {}
	}
}

impl From<reqwest::Error> for FaithError {
	fn from(err: reqwest::Error) -> Self {
		// Always include full error chain for debugging
		let mut msg = format!("{err:?}");
		let mut source = err.source();
		while let Some(e) = source {
			msg.push_str(&format!(" -> {e:?}"));
			source = e.source();
		}

		if err.is_timeout() {
			return FaithError::new(FaithErrorKind::Timeout, msg);
		}

		// A redirect the agent's own policy refused carries the kind we handed reqwest; one reqwest
		// raised on its own account stays a plain network error.
		let kind = err
			.is_redirect()
			.then(|| faith_kind_in_chain(&err))
			.flatten()
			.unwrap_or(FaithErrorKind::Network);

		FaithError::new(kind, msg)
	}
}

impl From<reqwest_middleware::Error> for FaithError {
	fn from(err: reqwest_middleware::Error) -> Self {
		match err {
			reqwest_middleware::Error::Middleware(err) => {
				FaithError::new(FaithErrorKind::Network, err.to_string())
			}
			reqwest_middleware::Error::Reqwest(err) => err.into(),
		}
	}
}

impl Error for FaithError {}

impl Display for FaithError {
	fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
		write!(
			f,
			"{:?}: {}",
			self.kind,
			self.message
				.as_deref()
				.unwrap_or_else(|| self.kind.default_message())
		)
	}
}

#[cfg(all(test, feature = "unstable-internals"))]
mod tests {
	use super::*;

	#[test]
	fn every_code_is_distinct_and_named() {
		let codes = error_codes();
		let unique: std::collections::BTreeSet<_> = codes.iter().collect();
		assert_eq!(unique.len(), codes.len(), "two kinds report the same code");
		assert!(codes.iter().all(|code| !code.is_empty()));
	}

	#[test]
	fn a_message_is_prefixed_with_the_code_it_reports() {
		for kind in FaithErrorKind::iter() {
			let code = kind.code();
			let rendered = FaithError::from(kind).to_string();
			assert!(
				rendered.starts_with(&format!("{code}: ")),
				"{rendered} does not lead with {code}"
			);
		}
	}

	#[test]
	fn a_kind_without_a_message_falls_back_to_its_own() {
		let err = FaithError::from(FaithErrorKind::Closed);
		assert_eq!(err.to_string(), "Closed: the agent has been closed");

		let err = FaithError::new(FaithErrorKind::Closed, "gone");
		assert_eq!(err.to_string(), "Closed: gone");
	}
}