Skip to main content

rightkit_http/
error.rs

1use std::fmt;
2use std::net::IpAddr;
3use std::time::Duration;
4
5use crate::netpolicy::{AddressClass, AddressDenial};
6
7/// Maximum error-body bytes retained on a [`HttpError::Status`].
8pub const MAX_ERROR_BODY_BYTES: usize = 64 * 1024;
9
10/// Where in a call an error happened. Carried by [`HttpError::Timeout`] and
11/// [`HttpError::Transport`] so apps can keep their own per-phase messages.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13#[non_exhaustive]
14pub enum Phase {
15    /// Name lookup, TCP connect or TLS handshake.
16    Connect,
17    /// Sending the request and waiting for the response headers.
18    Headers,
19    /// Reading the response body.
20    Body,
21    /// Any phase of a redirect hop after the first request, or an exhausted redirect limit.
22    Redirect,
23}
24
25impl Phase {
26    pub fn as_str(self) -> &'static str {
27        match self {
28            Phase::Connect => "connect",
29            Phase::Headers => "headers",
30            Phase::Body => "body",
31            Phase::Redirect => "redirect",
32        }
33    }
34}
35
36#[derive(Debug)]
37#[non_exhaustive]
38pub enum HttpError {
39    /// URL, header, or request construction failed before anything was sent.
40    InvalidRequest(String),
41    /// Connection, TLS, socket failure, or exhausted redirect limit.
42    /// `connect_phase` is true when nothing
43    /// can have reached the server (safe to retry any method); it is true exactly when
44    /// `phase` is [`Phase::Connect`].
45    Transport {
46        message: String,
47        connect_phase: bool,
48        phase: Phase,
49    },
50    /// A configured timeout elapsed. `message` names the elapsed timeout.
51    Timeout { message: String, phase: Phase },
52    /// Non-2xx response (after retries were exhausted or not applicable).
53    Status {
54        status: u16,
55        body: String,
56        retry_after: Option<Duration>,
57        attempts: u32,
58    },
59    /// Response body exceeded the configured limit.
60    BodyTooLarge { limit: usize },
61    /// Response was not valid JSON for the requested type.
62    Json(String),
63    /// Streaming protocol violation (oversized SSE line, bad UTF-8, ...).
64    Stream(String),
65    /// The call was cancelled through a [`crate::CancelToken`].
66    Cancelled,
67    /// [`ProxyMode::System`](crate::ProxyMode::System): the Windows proxy settings hold
68    /// something this version cannot follow exactly, named in `element`: different proxy
69    /// routes for HTTP and HTTPS, a bypass entry that is not a plain host (`<local>`, an entry
70    /// limited to one scheme, an inner wildcard), a proxy auto-config script, or automatic
71    /// detection. The client sends nothing: it never drops a bypass entry while still using
72    /// the proxy, and never sends one scheme to the other's proxy. Returned by
73    /// `Client::try_new` and by every request of a client built with `Client::new`. An app
74    /// catches it and uses the mode it used before (`ProxyMode::Environment`, or
75    /// `ProxyMode::Explicit`). Never retried.
76    SystemProxyUnsupported { element: String },
77    /// The outbound address policy refused the destination (see [`crate::netpolicy`]).
78    /// `addr` and `class` are `None` when the denial is about the name or scheme (no
79    /// address, a metadata host name, or a host outside a first-party list). `reason`
80    /// carries the detail, including custom `deny_if` text. Never retried.
81    AddressDenied {
82        host: String,
83        addr: Option<IpAddr>,
84        class: Option<AddressClass>,
85        reason: String,
86    },
87    /// [`crate::ClientConfig::https_only`] is set and the URL (or a redirect target when
88    /// `redirect` is true) is not `https`. `scheme` is lowercase, `host` has no
89    /// credentials. Nothing was sent to that URL. Never retried.
90    HttpsRequired {
91        host: String,
92        scheme: String,
93        redirect: bool,
94    },
95}
96
97impl From<AddressDenial> for HttpError {
98    fn from(d: AddressDenial) -> Self {
99        HttpError::AddressDenied {
100            host: d.host,
101            addr: d.addr,
102            class: d.class,
103            reason: d.reason,
104        }
105    }
106}
107
108/// Coarse class of an [`HttpError`], from [`HttpError::class`]. Apps map their own error
109/// classes and retry sets from this one table instead of matching statuses themselves.
110#[derive(Debug, Clone, Copy, PartialEq, Eq)]
111#[non_exhaustive]
112pub enum ErrorClass {
113    /// HTTP 401, 403 or 407.
114    Auth,
115    /// HTTP 429. `retry_after` is the parsed `Retry-After` delay, when the server sent one.
116    RateLimit { retry_after: Option<Duration> },
117    /// HTTP 5xx.
118    Server,
119    /// Any other status: 4xx not listed above, or an unfollowed 3xx.
120    Client,
121    /// A configured timeout elapsed, or the server answered HTTP 408 Request Timeout.
122    Timeout,
123    /// Connection, TLS or socket failure, or an exhausted redirect limit.
124    Transport,
125    /// The call was cancelled.
126    Cancelled,
127    /// The outbound address policy refused the destination.
128    AddressDenied,
129    /// The request could not be built, or the response body was malformed or oversized
130    /// (`InvalidRequest`, `Json`, `Stream`, `BodyTooLarge`), or `https_only` refused a URL
131    /// (`HttpsRequired`).
132    Invalid,
133}
134
135impl HttpError {
136    pub(crate) fn timeout(message: impl Into<String>, phase: Phase) -> Self {
137        HttpError::Timeout {
138            message: message.into(),
139            phase,
140        }
141    }
142
143    pub(crate) fn transport(message: impl Into<String>, phase: Phase) -> Self {
144        HttpError::Transport {
145            message: message.into(),
146            connect_phase: phase == Phase::Connect,
147            phase,
148        }
149    }
150
151    /// The [`Phase`] of a `Timeout` or `Transport` error; `None` for every other kind.
152    pub fn phase(&self) -> Option<Phase> {
153        match self {
154            HttpError::Timeout { phase, .. } | HttpError::Transport { phase, .. } => Some(*phase),
155            _ => None,
156        }
157    }
158
159    /// Re-label a `Timeout` or `Transport` error as happening during a redirect hop. A
160    /// transport error is no longer a pure connect failure, because earlier hops reached servers.
161    pub(crate) fn into_redirect_phase(self) -> Self {
162        match self {
163            HttpError::Timeout { message, .. } => HttpError::timeout(message, Phase::Redirect),
164            HttpError::Transport { message, .. } => HttpError::Transport {
165                message,
166                connect_phase: false,
167                phase: Phase::Redirect,
168            },
169            other => other,
170        }
171    }
172
173    pub fn status(&self) -> Option<u16> {
174        match self {
175            HttpError::Status { status, .. } => Some(*status),
176            _ => None,
177        }
178    }
179
180    /// The error's [`ErrorClass`]. See the README for the full table.
181    pub fn class(&self) -> ErrorClass {
182        match self {
183            HttpError::Status {
184                status,
185                retry_after,
186                ..
187            } => match *status {
188                401 | 403 | 407 => ErrorClass::Auth,
189                408 => ErrorClass::Timeout,
190                429 => ErrorClass::RateLimit {
191                    retry_after: *retry_after,
192                },
193                500..=599 => ErrorClass::Server,
194                _ => ErrorClass::Client,
195            },
196            HttpError::Timeout { .. } => ErrorClass::Timeout,
197            HttpError::Transport { .. } => ErrorClass::Transport,
198            HttpError::Cancelled => ErrorClass::Cancelled,
199            HttpError::SystemProxyUnsupported { .. } => ErrorClass::Invalid,
200            HttpError::AddressDenied { .. } => ErrorClass::AddressDenied,
201            HttpError::HttpsRequired { .. } => ErrorClass::Invalid,
202            HttpError::InvalidRequest(_)
203            | HttpError::Json(_)
204            | HttpError::Stream(_)
205            | HttpError::BodyTooLarge { .. } => ErrorClass::Invalid,
206        }
207    }
208}
209
210impl fmt::Display for HttpError {
211    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212        match self {
213            HttpError::InvalidRequest(m) => write!(f, "invalid request: {m}"),
214            HttpError::Transport { message, .. } => write!(f, "transport error: {message}"),
215            HttpError::Timeout { message, .. } => write!(f, "timeout: {message}"),
216            HttpError::Status {
217                status, attempts, ..
218            } => {
219                write!(f, "HTTP {status} after {attempts} attempt(s)")
220            }
221            HttpError::BodyTooLarge { limit } => write!(f, "response body exceeds {limit} bytes"),
222            HttpError::Json(m) => write!(f, "invalid JSON: {m}"),
223            HttpError::Stream(m) => write!(f, "stream error: {m}"),
224            HttpError::Cancelled => write!(f, "cancelled"),
225            HttpError::SystemProxyUnsupported { element } => write!(
226                f,
227                "the Windows proxy settings cannot be followed exactly by this version \
228                 ({element}); nothing was sent"
229            ),
230            HttpError::AddressDenied { host, reason, .. } => {
231                write!(f, "address denied for host '{host}': {reason}")
232            }
233            HttpError::HttpsRequired {
234                host,
235                scheme,
236                redirect,
237            } => {
238                let what = if *redirect {
239                    "redirect to"
240                } else {
241                    "request to"
242                };
243                write!(
244                    f,
245                    "https required: {what} '{scheme}' URL for host '{host}' refused"
246                )
247            }
248        }
249    }
250}
251
252impl std::error::Error for HttpError {}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    fn status(code: u16, retry_after: Option<Duration>) -> HttpError {
259        HttpError::Status {
260            status: code,
261            body: String::new(),
262            retry_after,
263            attempts: 1,
264        }
265    }
266
267    #[test]
268    fn statuses_map_to_their_classes() {
269        for code in [401, 403, 407] {
270            assert_eq!(status(code, None).class(), ErrorClass::Auth, "{code}");
271        }
272        for code in [500, 502, 503, 504, 599] {
273            assert_eq!(status(code, None).class(), ErrorClass::Server, "{code}");
274        }
275        for code in [400, 402, 404, 409, 410, 422, 451, 499, 301, 302] {
276            assert_eq!(status(code, None).class(), ErrorClass::Client, "{code}");
277        }
278    }
279
280    #[test]
281    fn http_408_is_a_timeout_not_a_client_error() {
282        assert_eq!(status(408, None).class(), ErrorClass::Timeout);
283        assert_eq!(status(408, None).status(), Some(408));
284    }
285
286    #[test]
287    fn rate_limit_carries_the_parsed_retry_after() {
288        assert_eq!(
289            status(429, Some(Duration::from_secs(7))).class(),
290            ErrorClass::RateLimit {
291                retry_after: Some(Duration::from_secs(7))
292            }
293        );
294        assert_eq!(
295            status(429, None).class(),
296            ErrorClass::RateLimit { retry_after: None }
297        );
298    }
299
300    #[test]
301    fn non_status_errors_map_to_their_classes() {
302        assert_eq!(
303            HttpError::timeout("x", Phase::Body).class(),
304            ErrorClass::Timeout
305        );
306        for phase in [Phase::Connect, Phase::Headers, Phase::Body, Phase::Redirect] {
307            assert_eq!(
308                HttpError::transport("x", phase).class(),
309                ErrorClass::Transport
310            );
311        }
312        assert_eq!(HttpError::Cancelled.class(), ErrorClass::Cancelled);
313        assert_eq!(
314            HttpError::AddressDenied {
315                host: "h".into(),
316                addr: None,
317                class: None,
318                reason: "r".into()
319            }
320            .class(),
321            ErrorClass::AddressDenied
322        );
323        assert_eq!(
324            HttpError::InvalidRequest("x".into()).class(),
325            ErrorClass::Invalid
326        );
327        assert_eq!(HttpError::Json("x".into()).class(), ErrorClass::Invalid);
328        assert_eq!(HttpError::Stream("x".into()).class(), ErrorClass::Invalid);
329        assert_eq!(
330            HttpError::BodyTooLarge { limit: 1 }.class(),
331            ErrorClass::Invalid
332        );
333    }
334
335    #[test]
336    fn phase_is_carried_by_timeout_and_transport_only() {
337        for phase in [Phase::Connect, Phase::Headers, Phase::Body, Phase::Redirect] {
338            assert_eq!(HttpError::timeout("t", phase).phase(), Some(phase));
339            assert_eq!(HttpError::transport("t", phase).phase(), Some(phase));
340        }
341        assert_eq!(HttpError::Cancelled.phase(), None);
342        assert_eq!(status(500, None).phase(), None);
343        assert_eq!(HttpError::InvalidRequest("x".into()).phase(), None);
344    }
345
346    #[test]
347    fn connect_phase_is_true_exactly_for_the_connect_phase() {
348        for (phase, want) in [
349            (Phase::Connect, true),
350            (Phase::Headers, false),
351            (Phase::Body, false),
352            (Phase::Redirect, false),
353        ] {
354            match HttpError::transport("t", phase) {
355                HttpError::Transport { connect_phase, .. } => assert_eq!(connect_phase, want),
356                other => panic!("{other:?}"),
357            }
358        }
359    }
360
361    #[test]
362    fn redirect_relabel_clears_connect_phase() {
363        match HttpError::transport("t", Phase::Connect).into_redirect_phase() {
364            HttpError::Transport {
365                connect_phase,
366                phase,
367                ..
368            } => {
369                assert!(!connect_phase);
370                assert_eq!(phase, Phase::Redirect);
371            }
372            other => panic!("{other:?}"),
373        }
374        assert_eq!(
375            HttpError::timeout("t", Phase::Headers)
376                .into_redirect_phase()
377                .phase(),
378            Some(Phase::Redirect)
379        );
380        assert!(matches!(
381            HttpError::Cancelled.into_redirect_phase(),
382            HttpError::Cancelled
383        ));
384    }
385
386    #[test]
387    fn phase_names() {
388        assert_eq!(Phase::Connect.as_str(), "connect");
389        assert_eq!(Phase::Headers.as_str(), "headers");
390        assert_eq!(Phase::Body.as_str(), "body");
391        assert_eq!(Phase::Redirect.as_str(), "redirect");
392    }
393}