Skip to main content

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, H3Error, 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) the
191    /// answer is decided when the error is constructed, from the transience of
192    /// 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    ///
297    /// A peer reset that arrives while the body streams is the same failure as
298    /// one that arrives before the head.
299    pub(crate) fn from_collect_error(err: CollectError, limit: Option<usize>) -> Error {
300        match err {
301            CollectError::TooLarge => {
302                Error::BodyTooLarge {
303                    limit: limit.unwrap_or(usize::MAX),
304                }
305            }
306            CollectError::H3(H3Error::Reset(code)) => Error::StreamReset { code },
307            _ => {
308                Error::Protocol {
309                    source: Box::new(err),
310                }
311            }
312        }
313    }
314
315    /// Maps a body-to-string collection error to the taxonomy.
316    pub(crate) fn from_collect_to_string_error(
317        err: CollectToStringError,
318        limit: Option<usize>,
319    ) -> Error {
320        match err {
321            CollectToStringError::TooLarge => {
322                Error::BodyTooLarge {
323                    limit: limit.unwrap_or(usize::MAX),
324                }
325            }
326            CollectToStringError::Utf8(source) => Error::InvalidBody { source },
327            CollectToStringError::H3(H3Error::Reset(code)) => Error::StreamReset { code },
328            CollectToStringError::H3(_) => {
329                Error::Protocol {
330                    source: Box::new(err),
331                }
332            }
333        }
334    }
335}
336
337impl From<BuildRequestError> for Error {
338    /// An invalid request is an [`Error::InvalidRequest`], so the convenience
339    /// entry points ([`Client::get`](crate::Client::get),
340    /// [`Client::post`](crate::Client::post)) report builder failures through
341    /// the same taxonomy as the rest of the request path.
342    fn from(error: BuildRequestError) -> Self {
343        Error::InvalidRequest {
344            reason: error.to_string().into(),
345        }
346    }
347}
348
349/// One failed connection attempt: binding the socket, or establishing the
350/// connection over it.
351#[derive(Debug, thiserror::Error)]
352pub(crate) enum AttemptError {
353    /// Binding a fresh socket for the attempt failed.
354    #[error("binding a socket failed")]
355    Bind(#[source] ScionSocketBindError),
356    /// Establishing the connection failed.
357    #[error("establishing the connection failed")]
358    Establish(#[source] EstablishError),
359}
360
361/// Aggregate of the failures of one attempt sequence, in completion order.
362/// The [`Error::Connect`] / [`Error::Tls`] source when every candidate
363/// failed.
364#[derive(Debug)]
365struct AllAttemptsFailed {
366    errors: Vec<AttemptError>,
367}
368
369impl fmt::Display for AllAttemptsFailed {
370    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
371        write!(
372            f,
373            "all {} connection attempt(s) failed: ",
374            self.errors.len()
375        )?;
376        for (i, err) in self.errors.iter().enumerate() {
377            if i > 0 {
378                write!(f, "; ")?;
379            }
380            write!(f, "{err}")?;
381        }
382        Ok(())
383    }
384}
385
386impl StdError for AllAttemptsFailed {
387    fn source(&self) -> Option<&(dyn StdError + 'static)> {
388        self.errors.first().map(|e| e as &(dyn StdError + 'static))
389    }
390}
391
392/// A [`Request`](crate::Request) could not be built.
393#[derive(Debug, thiserror::Error)]
394#[non_exhaustive]
395pub enum BuildRequestError {
396    /// No URL was provided to the builder.
397    #[error("no URL was provided")]
398    MissingUrl,
399    /// The URL could not be parsed.
400    #[error("invalid URL")]
401    InvalidUrl {
402        /// The underlying parse error.
403        #[source]
404        source: Box<dyn StdError + Send + Sync>,
405    },
406    /// The URL's scheme is not `https`. This is an HTTP/3-only client;
407    /// requests are never sent over cleartext or fall back to TCP.
408    #[error("unsupported URL scheme `{scheme}`, only `https` is supported")]
409    UnsupportedScheme {
410        /// The offending scheme.
411        scheme: String,
412    },
413    /// The URL has no host.
414    #[error("URL has no host")]
415    MissingHost,
416    /// The URL contains userinfo (`user:password@host`). HTTP/3 forbids
417    /// userinfo in `:authority` (RFC 9114 ยง4.3.1), and sending it would leak
418    /// the credentials on the wire; pass credentials in an `authorization`
419    /// header instead.
420    #[error("URL must not contain userinfo (credentials)")]
421    UserinfoNotAllowed,
422    /// A header name or value is invalid.
423    #[error("invalid header")]
424    InvalidHeader {
425        /// The underlying cause.
426        #[source]
427        source: Box<dyn StdError + Send + Sync>,
428    },
429}
430
431#[cfg(test)]
432mod tests {
433    use scion_quic::quic::cert_verifier::CertRejected;
434
435    use super::*;
436
437    #[test]
438    fn retryable_classification() {
439        let retryable = [
440            Error::StackBuild {
441                retryable: true,
442                source: "boom".into(),
443            },
444            Error::Connect {
445                host: "example.org".into(),
446                port: 443,
447                source: "boom".into(),
448            },
449            Error::StreamReset { code: 0x10c },
450            Error::ConnectionLimit,
451            Error::Timeout {
452                phase: TimeoutPhase::Request,
453                timeout: Duration::from_secs(1),
454            },
455            Error::TunnelRefused {
456                status: http::StatusCode::BAD_GATEWAY,
457            },
458        ];
459        for err in retryable {
460            assert!(err.is_retryable(), "{err}");
461        }
462
463        let not_retryable = [
464            Error::StackBuild {
465                retryable: false,
466                source: "boom".into(),
467            },
468            Error::Tls {
469                host: "example.org".into(),
470                source: "boom".into(),
471            },
472            Error::Protocol {
473                source: "boom".into(),
474            },
475            Error::BodyTooLarge { limit: 16 },
476            Error::InvalidRequest {
477                reason: "bad".into(),
478            },
479            Error::TunnelRefused {
480                status: http::StatusCode::NOT_FOUND,
481            },
482            Error::Closed,
483        ];
484        for err in not_retryable {
485            assert!(!err.is_retryable(), "{err}");
486        }
487    }
488
489    #[test]
490    fn resolution_retryability_follows_failure_kind() {
491        let lookup = Error::from_resolve_error(
492            "example.org",
493            ResolveError::DnsLookup("timed out".to_string()),
494        );
495        assert!(lookup.is_retryable());
496
497        let no_entries = Error::from_resolve_error(
498            "example.org",
499            ResolveError::NoValidEntries {
500                domain: "example.org".to_string(),
501                invalid_entries: vec![],
502            },
503        );
504        assert!(!no_entries.is_retryable());
505    }
506
507    #[test]
508    fn establish_errors_map_to_tls_only_when_uniform() {
509        let tls = Error::from_attempt_errors(
510            "example.org",
511            443,
512            vec![
513                AttemptError::Establish(EstablishError::AlpnMismatch),
514                AttemptError::Establish(EstablishError::AlpnMismatch),
515            ],
516        );
517        assert!(matches!(tls, Error::Tls { .. }));
518
519        let mixed = Error::from_attempt_errors(
520            "example.org",
521            443,
522            vec![
523                AttemptError::Establish(EstablishError::AlpnMismatch),
524                AttemptError::Establish(EstablishError::Handshake),
525            ],
526        );
527        assert!(matches!(mixed, Error::Connect { .. }));
528    }
529
530    #[test]
531    fn a_rejected_certificate_is_a_tls_error() {
532        // A verifier that refuses the chain refuses it again on a retry, so the
533        // failure is deterministic rather than a connect failure to retry.
534        let rejected = Error::from_attempt_errors(
535            "example.org",
536            443,
537            vec![AttemptError::Establish(
538                EstablishError::CertificateRejected(CertRejected::new("not the pinned server")),
539            )],
540        );
541        assert!(matches!(rejected, Error::Tls { .. }));
542        assert!(!rejected.is_retryable());
543        assert!(rejected.to_string().contains("example.org"));
544    }
545
546    #[test]
547    fn a_reset_while_the_body_streams_is_a_stream_reset() {
548        let collected = Error::from_collect_error(CollectError::H3(H3Error::Reset(0x10c)), None);
549        assert!(matches!(collected, Error::StreamReset { code: 0x10c }));
550        assert!(collected.is_retryable());
551
552        let text = Error::from_collect_to_string_error(
553            CollectToStringError::H3(H3Error::Reset(0x10c)),
554            None,
555        );
556        assert!(matches!(text, Error::StreamReset { code: 0x10c }));
557
558        let closed = Error::from_collect_error(CollectError::H3(H3Error::ConnectionClosed), None);
559        assert!(matches!(closed, Error::Protocol { .. }));
560    }
561
562    #[test]
563    fn stream_level_request_errors_do_not_become_connect() {
564        let reset = Error::from_h3_request_error("example.org", 443, H3RequestError::Reset(7));
565        assert!(matches!(reset, Error::StreamReset { code: 7 }));
566
567        let blocked =
568            Error::from_h3_request_error("example.org", 443, H3RequestError::StreamBlocked);
569        assert!(matches!(blocked, Error::ConnectionLimit));
570    }
571}