use std::{borrow::Cow, error::Error as StdError, fmt, time::Duration};
use scion_quic::{
h3::client::{
CollectError, CollectToStringError, EstablishError, H3Error, RequestError as H3RequestError,
},
reexport::squiche,
};
use scion_stack::{resolver::ResolveError, stack::ScionSocketBindError};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum TimeoutPhase {
Connect,
Request,
Body,
}
impl fmt::Display for TimeoutPhase {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
TimeoutPhase::Connect => write!(f, "connect"),
TimeoutPhase::Request => write!(f, "request"),
TimeoutPhase::Body => write!(f, "response body collection"),
}
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum Error {
#[error("failed to build SCION connectivity")]
StackBuild {
retryable: bool,
#[source]
source: Box<dyn StdError + Send + Sync>,
},
#[error("SCION name resolution failed for {host}")]
Resolution {
host: String,
retryable: bool,
#[source]
source: ResolveError,
},
#[error("could not connect to {host}:{port}")]
Connect {
host: String,
port: u16,
#[source]
source: Box<dyn StdError + Send + Sync>,
},
#[error("TLS failure connecting to {host}")]
Tls {
host: String,
#[source]
source: Box<dyn StdError + Send + Sync>,
},
#[error("stream reset by peer (code {code})")]
StreamReset {
code: u64,
},
#[error("HTTP/3 protocol error")]
Protocol {
#[source]
source: Box<dyn StdError + Send + Sync>,
},
#[error("peer's concurrent stream limit reached")]
ConnectionLimit,
#[error("tunnel refused with status {status}")]
TunnelRefused {
status: http::StatusCode,
},
#[error("response body exceeded {limit} bytes")]
BodyTooLarge {
limit: usize,
},
#[error("{phase} timed out after {timeout:?}")]
Timeout {
phase: TimeoutPhase,
timeout: Duration,
},
#[error("response body is not valid UTF-8")]
InvalidBody {
#[source]
source: std::string::FromUtf8Error,
},
#[error("invalid request: {reason}")]
InvalidRequest {
reason: Cow<'static, str>,
},
#[error("client is closed")]
Closed,
}
impl Error {
#[must_use]
pub fn is_retryable(&self) -> bool {
match self {
Error::StackBuild { retryable, .. } | Error::Resolution { retryable, .. } => *retryable,
Error::TunnelRefused { status } => status.is_server_error(),
Error::Connect { .. }
| Error::StreamReset { .. }
| Error::ConnectionLimit
| Error::Timeout { .. } => true,
Error::Tls { .. }
| Error::Protocol { .. }
| Error::BodyTooLarge { .. }
| Error::InvalidBody { .. }
| Error::InvalidRequest { .. }
| Error::Closed => false,
}
}
pub(crate) fn from_h3_request_error(host: &str, port: u16, err: H3RequestError) -> Error {
match err {
H3RequestError::Establish(e) => {
Error::from_attempt_errors(host, port, vec![AttemptError::Establish(e)])
}
H3RequestError::Reset(code) => Error::StreamReset { code },
H3RequestError::StreamBlocked
| H3RequestError::H3(squiche::h3::Error::TransportError(squiche::Error::StreamLimit)) => {
Error::ConnectionLimit
}
H3RequestError::H3(_) => {
Error::Protocol {
source: Box::new(err),
}
}
H3RequestError::ConnectionClosed | H3RequestError::LocallyClosed => {
Error::Connect {
host: host.to_string(),
port,
source: Box::new(err),
}
}
}
}
pub(crate) fn from_attempt_errors(host: &str, port: u16, errors: Vec<AttemptError>) -> Error {
let all_tls = !errors.is_empty()
&& errors.iter().all(|e| {
matches!(
e,
AttemptError::Establish(
EstablishError::AlpnMismatch | EstablishError::CertificateRejected(_)
)
)
});
let source = Box::new(AllAttemptsFailed { errors });
if all_tls {
Error::Tls {
host: host.to_string(),
source,
}
} else {
Error::Connect {
host: host.to_string(),
port,
source,
}
}
}
pub(crate) fn from_resolve_error(host: &str, err: ResolveError) -> Error {
let retryable = err.is_transient();
Error::Resolution {
host: host.to_string(),
retryable,
source: err,
}
}
pub(crate) fn from_collect_error(err: CollectError, limit: Option<usize>) -> Error {
match err {
CollectError::TooLarge => {
Error::BodyTooLarge {
limit: limit.unwrap_or(usize::MAX),
}
}
CollectError::H3(H3Error::Reset(code)) => Error::StreamReset { code },
_ => {
Error::Protocol {
source: Box::new(err),
}
}
}
}
pub(crate) fn from_collect_to_string_error(
err: CollectToStringError,
limit: Option<usize>,
) -> Error {
match err {
CollectToStringError::TooLarge => {
Error::BodyTooLarge {
limit: limit.unwrap_or(usize::MAX),
}
}
CollectToStringError::Utf8(source) => Error::InvalidBody { source },
CollectToStringError::H3(H3Error::Reset(code)) => Error::StreamReset { code },
CollectToStringError::H3(_) => {
Error::Protocol {
source: Box::new(err),
}
}
}
}
}
impl From<BuildRequestError> for Error {
fn from(error: BuildRequestError) -> Self {
Error::InvalidRequest {
reason: error.to_string().into(),
}
}
}
#[derive(Debug, thiserror::Error)]
pub(crate) enum AttemptError {
#[error("binding a socket failed")]
Bind(#[source] ScionSocketBindError),
#[error("establishing the connection failed")]
Establish(#[source] EstablishError),
}
#[derive(Debug)]
struct AllAttemptsFailed {
errors: Vec<AttemptError>,
}
impl fmt::Display for AllAttemptsFailed {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(
f,
"all {} connection attempt(s) failed: ",
self.errors.len()
)?;
for (i, err) in self.errors.iter().enumerate() {
if i > 0 {
write!(f, "; ")?;
}
write!(f, "{err}")?;
}
Ok(())
}
}
impl StdError for AllAttemptsFailed {
fn source(&self) -> Option<&(dyn StdError + 'static)> {
self.errors.first().map(|e| e as &(dyn StdError + 'static))
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum BuildRequestError {
#[error("no URL was provided")]
MissingUrl,
#[error("invalid URL")]
InvalidUrl {
#[source]
source: Box<dyn StdError + Send + Sync>,
},
#[error("unsupported URL scheme `{scheme}`, only `https` is supported")]
UnsupportedScheme {
scheme: String,
},
#[error("URL has no host")]
MissingHost,
#[error("URL must not contain userinfo (credentials)")]
UserinfoNotAllowed,
#[error("invalid header")]
InvalidHeader {
#[source]
source: Box<dyn StdError + Send + Sync>,
},
}
#[cfg(test)]
mod tests {
use scion_quic::quic::cert_verifier::CertRejected;
use super::*;
#[test]
fn retryable_classification() {
let retryable = [
Error::StackBuild {
retryable: true,
source: "boom".into(),
},
Error::Connect {
host: "example.org".into(),
port: 443,
source: "boom".into(),
},
Error::StreamReset { code: 0x10c },
Error::ConnectionLimit,
Error::Timeout {
phase: TimeoutPhase::Request,
timeout: Duration::from_secs(1),
},
Error::TunnelRefused {
status: http::StatusCode::BAD_GATEWAY,
},
];
for err in retryable {
assert!(err.is_retryable(), "{err}");
}
let not_retryable = [
Error::StackBuild {
retryable: false,
source: "boom".into(),
},
Error::Tls {
host: "example.org".into(),
source: "boom".into(),
},
Error::Protocol {
source: "boom".into(),
},
Error::BodyTooLarge { limit: 16 },
Error::InvalidRequest {
reason: "bad".into(),
},
Error::TunnelRefused {
status: http::StatusCode::NOT_FOUND,
},
Error::Closed,
];
for err in not_retryable {
assert!(!err.is_retryable(), "{err}");
}
}
#[test]
fn resolution_retryability_follows_failure_kind() {
let lookup = Error::from_resolve_error(
"example.org",
ResolveError::DnsLookup("timed out".to_string()),
);
assert!(lookup.is_retryable());
let no_entries = Error::from_resolve_error(
"example.org",
ResolveError::NoValidEntries {
domain: "example.org".to_string(),
invalid_entries: vec![],
},
);
assert!(!no_entries.is_retryable());
}
#[test]
fn establish_errors_map_to_tls_only_when_uniform() {
let tls = Error::from_attempt_errors(
"example.org",
443,
vec![
AttemptError::Establish(EstablishError::AlpnMismatch),
AttemptError::Establish(EstablishError::AlpnMismatch),
],
);
assert!(matches!(tls, Error::Tls { .. }));
let mixed = Error::from_attempt_errors(
"example.org",
443,
vec![
AttemptError::Establish(EstablishError::AlpnMismatch),
AttemptError::Establish(EstablishError::Handshake),
],
);
assert!(matches!(mixed, Error::Connect { .. }));
}
#[test]
fn a_rejected_certificate_is_a_tls_error() {
let rejected = Error::from_attempt_errors(
"example.org",
443,
vec![AttemptError::Establish(
EstablishError::CertificateRejected(CertRejected::new("not the pinned server")),
)],
);
assert!(matches!(rejected, Error::Tls { .. }));
assert!(!rejected.is_retryable());
assert!(rejected.to_string().contains("example.org"));
}
#[test]
fn a_reset_while_the_body_streams_is_a_stream_reset() {
let collected = Error::from_collect_error(CollectError::H3(H3Error::Reset(0x10c)), None);
assert!(matches!(collected, Error::StreamReset { code: 0x10c }));
assert!(collected.is_retryable());
let text = Error::from_collect_to_string_error(
CollectToStringError::H3(H3Error::Reset(0x10c)),
None,
);
assert!(matches!(text, Error::StreamReset { code: 0x10c }));
let closed = Error::from_collect_error(CollectError::H3(H3Error::ConnectionClosed), None);
assert!(matches!(closed, Error::Protocol { .. }));
}
#[test]
fn stream_level_request_errors_do_not_become_connect() {
let reset = Error::from_h3_request_error("example.org", 443, H3RequestError::Reset(7));
assert!(matches!(reset, Error::StreamReset { code: 7 }));
let blocked =
Error::from_h3_request_error("example.org", 443, H3RequestError::StreamBlocked);
assert!(matches!(blocked, Error::ConnectionLimit));
}
}