scion_http3/error.rs
1// Copyright 2026 Anapaya Systems
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! The error taxonomy of the HTTP/3 client.
16//!
17//! All request-path operations ([`Client::request`](crate::Client::request)
18//! and friends, and body collection on [`Response`](crate::Response)) share
19//! one failure domain and therefore one error type, [`Error`]: whatever the
20//! operation, the caller's decisions are the same, i.e., retry, fix the request,
21//! or give up, and [`Error::is_retryable`] answers the first one directly.
22//! Constructing a [`Request`](crate::Request) is the exception. It fails with
23//! the more specific [`BuildRequestError`] before any I/O happens.
24
25use std::{borrow::Cow, error::Error as StdError, fmt, time::Duration};
26
27use scion_quic::{
28 h3::client::{
29 CollectError, CollectToStringError, EstablishError, RequestError as H3RequestError,
30 },
31 reexport::squiche,
32};
33use scion_stack::{resolver::ResolveError, stack::ScionSocketBindError};
34
35/// The phase of a request during which a timeout fired.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37#[non_exhaustive]
38pub enum TimeoutPhase {
39 /// Establishing a connection to the origin (bounded by
40 /// [`Config::with_connect_timeout`](crate::Config::with_connect_timeout)).
41 Connect,
42 /// Waiting for the response head (bounded by
43 /// [`Config::with_request_timeout`](crate::Config::with_request_timeout)
44 /// or the per-request override).
45 Request,
46 /// Collecting the response body (bounded by the remainder of the request
47 /// timeout).
48 ///
49 /// That remainder shrinks while the caller holds the
50 /// [`Response`](crate::Response), so this can also mean "the response was
51 /// held too long before being collected" rather than "the body was slow".
52 Body,
53}
54
55impl fmt::Display for TimeoutPhase {
56 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57 match self {
58 TimeoutPhase::Connect => write!(f, "connect"),
59 TimeoutPhase::Request => write!(f, "request"),
60 TimeoutPhase::Body => write!(f, "response body collection"),
61 }
62 }
63}
64
65/// Any failure on the request path.
66///
67/// The variants follow the phases of a request: building connectivity,
68/// resolving the origin, connecting to it, and exchanging the request and
69/// response. [`is_retryable`](Self::is_retryable) classifies each error once,
70/// so callers branch on that instead of re-deriving the answer from the
71/// variant.
72#[derive(Debug, thiserror::Error)]
73#[non_exhaustive]
74pub enum Error {
75 /// Building SCION connectivity (the stack or the resolver) failed.
76 #[error("failed to build SCION connectivity")]
77 StackBuild {
78 /// Whether retrying may succeed (all underlying failures were
79 /// transient).
80 retryable: bool,
81 /// The underlying cause.
82 #[source]
83 source: Box<dyn StdError + Send + Sync>,
84 },
85 /// SCION name resolution for the origin's host failed: the DNS lookup
86 /// itself failed, or the host has no valid TSAR TXT records.
87 #[error("SCION name resolution failed for {host}")]
88 Resolution {
89 /// The host that failed to resolve.
90 host: String,
91 /// Whether retrying may succeed (the lookup failed, as opposed to the
92 /// host having no valid records).
93 retryable: bool,
94 /// The underlying resolver error.
95 #[source]
96 source: ResolveError,
97 },
98 /// No connection to the origin could be established: every candidate
99 /// address failed with a socket, QUIC, or handshake error.
100 #[error("could not connect to {host}:{port}")]
101 Connect {
102 /// The origin's host.
103 host: String,
104 /// The origin's port.
105 port: u16,
106 /// The underlying cause(s).
107 #[source]
108 source: Box<dyn StdError + Send + Sync>,
109 },
110 /// The TLS layer rejected the origin: every candidate negotiated an ALPN
111 /// other than HTTP/3.
112 ///
113 /// A certificate that fails to validate does *not* land here: the transport
114 /// reports it as a failed handshake, indistinguishable from an unreachable
115 /// peer, so it surfaces as [`Connect`](Self::Connect).
116 #[error("TLS failure connecting to {host}")]
117 Tls {
118 /// The origin's host (the certificate-validation identity).
119 host: String,
120 /// The underlying cause.
121 #[source]
122 source: Box<dyn StdError + Send + Sync>,
123 },
124 /// The peer reset the request stream.
125 #[error("stream reset by peer (code {code})")]
126 StreamReset {
127 /// The HTTP/3 application error code carried by the reset.
128 code: u64,
129 },
130 /// An HTTP/3 protocol violation occurred on an established connection.
131 #[error("HTTP/3 protocol error")]
132 Protocol {
133 /// The underlying cause.
134 #[source]
135 source: Box<dyn StdError + Send + Sync>,
136 },
137 /// The peer's concurrent-stream limit is exhausted; the request was never
138 /// sent.
139 #[error("peer's concurrent stream limit reached")]
140 ConnectionLimit,
141 /// The server refused the `CONNECT` tunnel with a non-2xx status.
142 #[error("tunnel refused with status {status}")]
143 TunnelRefused {
144 /// The status of the `CONNECT` response.
145 status: http::StatusCode,
146 },
147 /// The response body exceeded the size limit passed to
148 /// [`Response::bytes`](crate::Response::bytes) or
149 /// [`Response::text`](crate::Response::text).
150 #[error("response body exceeded {limit} bytes")]
151 BodyTooLarge {
152 /// The limit that was exceeded.
153 limit: usize,
154 },
155 /// A deadline expired.
156 #[error("{phase} timed out after {timeout:?}")]
157 Timeout {
158 /// The phase of the request that timed out.
159 phase: TimeoutPhase,
160 /// The deadline that expired.
161 timeout: Duration,
162 },
163 /// The response body is not valid UTF-8 (only from
164 /// [`Response::text`](crate::Response::text)).
165 #[error("response body is not valid UTF-8")]
166 InvalidBody {
167 /// The underlying UTF-8 error.
168 #[source]
169 source: std::string::FromUtf8Error,
170 },
171 /// The request is invalid (malformed URL, unsupported scheme, missing
172 /// host, or an invalid header).
173 #[error("invalid request: {reason}")]
174 InvalidRequest {
175 /// What is wrong with the request.
176 reason: Cow<'static, str>,
177 },
178 /// The client has been closed with [`Client::close`](crate::Client::close).
179 #[error("client is closed")]
180 Closed,
181}
182
183impl Error {
184 /// Whether retrying the request may succeed.
185 ///
186 /// Transient conditions (connectivity, timeouts, stream resets, stream
187 /// limits, a 5xx tunnel refusal) are retryable. Deterministic ones (TLS
188 /// rejection, protocol violations, invalid requests, oversized bodies, a
189 /// 4xx tunnel refusal, a closed client) are not. For
190 /// [`StackBuild`](Self::StackBuild) and [`Resolution`](Self::Resolution)
191 /// the answer is decided when the error is constructed, from the
192 /// transience of the underlying failures.
193 ///
194 /// A retryable error never implies the request did not reach the server:
195 /// only the caller knows whether its request is idempotent.
196 #[must_use]
197 pub fn is_retryable(&self) -> bool {
198 match self {
199 Error::StackBuild { retryable, .. } | Error::Resolution { retryable, .. } => *retryable,
200 Error::TunnelRefused { status } => status.is_server_error(),
201 Error::Connect { .. }
202 | Error::StreamReset { .. }
203 | Error::ConnectionLimit
204 | Error::Timeout { .. } => true,
205 Error::Tls { .. }
206 | Error::Protocol { .. }
207 | Error::BodyTooLarge { .. }
208 | Error::InvalidBody { .. }
209 | Error::InvalidRequest { .. }
210 | Error::Closed => false,
211 }
212 }
213
214 /// Maps a transport-level request error to the taxonomy, attributing
215 /// connection-level failures to `host:port`.
216 pub(crate) fn from_h3_request_error(host: &str, port: u16, err: H3RequestError) -> Error {
217 match err {
218 H3RequestError::Establish(e) => {
219 Error::from_attempt_errors(host, port, vec![AttemptError::Establish(e)])
220 }
221 H3RequestError::Reset(code) => Error::StreamReset { code },
222 // Both shapes mean the same thing to the caller: the peer's
223 // concurrent-stream limit has no room for this request.
224 H3RequestError::StreamBlocked
225 | H3RequestError::H3(squiche::h3::Error::TransportError(squiche::Error::StreamLimit)) => {
226 Error::ConnectionLimit
227 }
228 H3RequestError::H3(_) => {
229 Error::Protocol {
230 source: Box::new(err),
231 }
232 }
233 H3RequestError::ConnectionClosed | H3RequestError::LocallyClosed => {
234 Error::Connect {
235 host: host.to_string(),
236 port,
237 source: Box::new(err),
238 }
239 }
240 }
241 }
242
243 /// Maps the failures of an attempt sequence to the taxonomy: TLS-level
244 /// rejections become [`Error::Tls`], everything else [`Error::Connect`].
245 ///
246 /// The classification is deliberately unanimous rather than "any": one
247 /// candidate rejecting the ALPN says nothing about the others, which may
248 /// have failed for transient reasons and may well work on a retry. Only
249 /// when *every* candidate rejected us is the failure deterministic, and
250 /// therefore not worth retrying.
251 pub(crate) fn from_attempt_errors(host: &str, port: u16, errors: Vec<AttemptError>) -> Error {
252 // XXX(shitz): A chain that fails against the trust anchors should be an
253 // `Error::Tls` too, but cannot be recognized here: scion-quic's
254 // handshake loop discards the cause and reports
255 // `EstablishError::Handshake`, which is what an unreachable peer also
256 // produces. Such failures are therefore reported as retryable
257 // `Error::Connect`. A chain that a caller-supplied verifier refuses is
258 // recognized, because that path keeps its cause.
259 let all_tls = !errors.is_empty()
260 && errors.iter().all(|e| {
261 matches!(
262 e,
263 AttemptError::Establish(
264 EstablishError::AlpnMismatch | EstablishError::CertificateRejected(_)
265 )
266 )
267 });
268 let source = Box::new(AllAttemptsFailed { errors });
269 if all_tls {
270 Error::Tls {
271 host: host.to_string(),
272 source,
273 }
274 } else {
275 Error::Connect {
276 host: host.to_string(),
277 port,
278 source,
279 }
280 }
281 }
282
283 /// Maps a resolver error to [`Error::Resolution`], taking retryability from
284 /// the resolver's own classification: a failed lookup may succeed later, a
285 /// host without valid TSAR records will not.
286 pub(crate) fn from_resolve_error(host: &str, err: ResolveError) -> Error {
287 let retryable = err.is_transient();
288 Error::Resolution {
289 host: host.to_string(),
290 retryable,
291 source: err,
292 }
293 }
294
295 /// Maps a body-collection error to the taxonomy.
296 pub(crate) fn from_collect_error(err: CollectError, limit: Option<usize>) -> Error {
297 match err {
298 CollectError::TooLarge => {
299 Error::BodyTooLarge {
300 limit: limit.unwrap_or(usize::MAX),
301 }
302 }
303 _ => {
304 Error::Protocol {
305 source: Box::new(err),
306 }
307 }
308 }
309 }
310
311 /// Maps a body-to-string collection error to the taxonomy.
312 pub(crate) fn from_collect_to_string_error(
313 err: CollectToStringError,
314 limit: Option<usize>,
315 ) -> Error {
316 match err {
317 CollectToStringError::TooLarge => {
318 Error::BodyTooLarge {
319 limit: limit.unwrap_or(usize::MAX),
320 }
321 }
322 CollectToStringError::Utf8(source) => Error::InvalidBody { source },
323 CollectToStringError::H3(_) => {
324 Error::Protocol {
325 source: Box::new(err),
326 }
327 }
328 }
329 }
330}
331
332impl From<BuildRequestError> for Error {
333 /// An invalid request is an [`Error::InvalidRequest`], so the convenience
334 /// entry points ([`Client::get`](crate::Client::get),
335 /// [`Client::post`](crate::Client::post)) report builder failures through
336 /// the same taxonomy as the rest of the request path.
337 fn from(error: BuildRequestError) -> Self {
338 Error::InvalidRequest {
339 reason: error.to_string().into(),
340 }
341 }
342}
343
344/// One failed connection attempt: binding the socket, or establishing the
345/// connection over it.
346#[derive(Debug, thiserror::Error)]
347pub(crate) enum AttemptError {
348 /// Binding a fresh socket for the attempt failed.
349 #[error("binding a socket failed")]
350 Bind(#[source] ScionSocketBindError),
351 /// Establishing the connection failed.
352 #[error("establishing the connection failed")]
353 Establish(#[source] EstablishError),
354}
355
356/// Aggregate of the failures of one attempt sequence, in completion order.
357/// The [`Error::Connect`] / [`Error::Tls`] source when every candidate
358/// failed.
359#[derive(Debug)]
360struct AllAttemptsFailed {
361 errors: Vec<AttemptError>,
362}
363
364impl fmt::Display for AllAttemptsFailed {
365 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
366 write!(
367 f,
368 "all {} connection attempt(s) failed: ",
369 self.errors.len()
370 )?;
371 for (i, err) in self.errors.iter().enumerate() {
372 if i > 0 {
373 write!(f, "; ")?;
374 }
375 write!(f, "{err}")?;
376 }
377 Ok(())
378 }
379}
380
381impl StdError for AllAttemptsFailed {
382 fn source(&self) -> Option<&(dyn StdError + 'static)> {
383 self.errors.first().map(|e| e as &(dyn StdError + 'static))
384 }
385}
386
387/// A [`Request`](crate::Request) could not be built.
388#[derive(Debug, thiserror::Error)]
389#[non_exhaustive]
390pub enum BuildRequestError {
391 /// No URL was provided to the builder.
392 #[error("no URL was provided")]
393 MissingUrl,
394 /// The URL could not be parsed.
395 #[error("invalid URL")]
396 InvalidUrl {
397 /// The underlying parse error.
398 #[source]
399 source: Box<dyn StdError + Send + Sync>,
400 },
401 /// The URL's scheme is not `https`. This is an HTTP/3-only client;
402 /// requests are never sent over cleartext or fall back to TCP.
403 #[error("unsupported URL scheme `{scheme}`, only `https` is supported")]
404 UnsupportedScheme {
405 /// The offending scheme.
406 scheme: String,
407 },
408 /// The URL has no host.
409 #[error("URL has no host")]
410 MissingHost,
411 /// The URL contains userinfo (`user:password@host`). HTTP/3 forbids
412 /// userinfo in `:authority` (RFC 9114 ยง4.3.1), and sending it would leak
413 /// the credentials on the wire; pass credentials in an `authorization`
414 /// header instead.
415 #[error("URL must not contain userinfo (credentials)")]
416 UserinfoNotAllowed,
417 /// A header name or value is invalid.
418 #[error("invalid header")]
419 InvalidHeader {
420 /// The underlying cause.
421 #[source]
422 source: Box<dyn StdError + Send + Sync>,
423 },
424}
425
426#[cfg(test)]
427mod tests {
428 use scion_quic::quic::cert_verifier::CertRejected;
429
430 use super::*;
431
432 #[test]
433 fn retryable_classification() {
434 let retryable = [
435 Error::StackBuild {
436 retryable: true,
437 source: "boom".into(),
438 },
439 Error::Connect {
440 host: "example.org".into(),
441 port: 443,
442 source: "boom".into(),
443 },
444 Error::StreamReset { code: 0x10c },
445 Error::ConnectionLimit,
446 Error::Timeout {
447 phase: TimeoutPhase::Request,
448 timeout: Duration::from_secs(1),
449 },
450 Error::TunnelRefused {
451 status: http::StatusCode::BAD_GATEWAY,
452 },
453 ];
454 for err in retryable {
455 assert!(err.is_retryable(), "{err}");
456 }
457
458 let not_retryable = [
459 Error::StackBuild {
460 retryable: false,
461 source: "boom".into(),
462 },
463 Error::Tls {
464 host: "example.org".into(),
465 source: "boom".into(),
466 },
467 Error::Protocol {
468 source: "boom".into(),
469 },
470 Error::BodyTooLarge { limit: 16 },
471 Error::InvalidRequest {
472 reason: "bad".into(),
473 },
474 Error::TunnelRefused {
475 status: http::StatusCode::NOT_FOUND,
476 },
477 Error::Closed,
478 ];
479 for err in not_retryable {
480 assert!(!err.is_retryable(), "{err}");
481 }
482 }
483
484 #[test]
485 fn resolution_retryability_follows_failure_kind() {
486 let lookup = Error::from_resolve_error(
487 "example.org",
488 ResolveError::DnsLookup("timed out".to_string()),
489 );
490 assert!(lookup.is_retryable());
491
492 let no_entries = Error::from_resolve_error(
493 "example.org",
494 ResolveError::NoValidEntries {
495 domain: "example.org".to_string(),
496 invalid_entries: vec![],
497 },
498 );
499 assert!(!no_entries.is_retryable());
500 }
501
502 #[test]
503 fn establish_errors_map_to_tls_only_when_uniform() {
504 let tls = Error::from_attempt_errors(
505 "example.org",
506 443,
507 vec![
508 AttemptError::Establish(EstablishError::AlpnMismatch),
509 AttemptError::Establish(EstablishError::AlpnMismatch),
510 ],
511 );
512 assert!(matches!(tls, Error::Tls { .. }));
513
514 let mixed = Error::from_attempt_errors(
515 "example.org",
516 443,
517 vec![
518 AttemptError::Establish(EstablishError::AlpnMismatch),
519 AttemptError::Establish(EstablishError::Handshake),
520 ],
521 );
522 assert!(matches!(mixed, Error::Connect { .. }));
523 }
524
525 #[test]
526 fn a_rejected_certificate_is_a_tls_error() {
527 // A verifier that refuses the chain refuses it again on a retry, so the
528 // failure is deterministic rather than a connect failure to retry.
529 let rejected = Error::from_attempt_errors(
530 "example.org",
531 443,
532 vec![AttemptError::Establish(
533 EstablishError::CertificateRejected(CertRejected::new("not the pinned server")),
534 )],
535 );
536 assert!(matches!(rejected, Error::Tls { .. }));
537 assert!(!rejected.is_retryable());
538 assert!(rejected.to_string().contains("example.org"));
539 }
540
541 #[test]
542 fn stream_level_request_errors_do_not_become_connect() {
543 let reset = Error::from_h3_request_error("example.org", 443, H3RequestError::Reset(7));
544 assert!(matches!(reset, Error::StreamReset { code: 7 }));
545
546 let blocked =
547 Error::from_h3_request_error("example.org", 443, H3RequestError::StreamBlocked);
548 assert!(matches!(blocked, Error::ConnectionLimit));
549 }
550}