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, RedirectRefusal};
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), or a proxy auto-config script. The client
71    /// sends nothing: it never drops a bypass entry while still using the proxy, and never
72    /// sends one scheme to the other's proxy. "Automatically detect settings" alone is not
73    /// refused (since 0.3.1; see `ProxyModeUsed`). Returned by
74    /// `Client::try_new` and by every request of a client built with `Client::new`. An app
75    /// catches it and uses the mode it used before (`ProxyMode::Environment`, or
76    /// `ProxyMode::Explicit`). Never retried.
77    SystemProxyUnsupported { element: String },
78    /// The outbound address policy refused the destination (see [`crate::netpolicy`]).
79    /// `addr` and `class` are `None` when the denial is about the name or scheme (no
80    /// address, a metadata host name, or a host outside a first-party list). `reason`
81    /// carries the detail, including custom `deny_if` text. Never retried.
82    AddressDenied {
83        host: String,
84        addr: Option<IpAddr>,
85        class: Option<AddressClass>,
86        reason: String,
87    },
88    /// [`crate::ClientConfig::https_only`] is set and the URL (or a redirect target when
89    /// `redirect` is true) is not `https`. `scheme` is lowercase, `host` has no
90    /// credentials. Nothing was sent to that URL. Never retried.
91    HttpsRequired {
92        host: String,
93        scheme: String,
94        redirect: bool,
95    },
96    /// A redirect target was refused before anything was sent to it: its origin is outside
97    /// the request's [`crate::ApprovedOrigins`], or its URL carries userinfo or an unsafe
98    /// shape (see [`RedirectRefusal`]). `host` is the target's host, without userinfo, path
99    /// or query. The earlier hops were sent. Never retried, since the same redirect would be
100    /// refused again.
101    RedirectRefused {
102        host: String,
103        reason: RedirectRefusal,
104    },
105}
106
107impl From<AddressDenial> for HttpError {
108    fn from(d: AddressDenial) -> Self {
109        HttpError::AddressDenied {
110            host: d.host,
111            addr: d.addr,
112            class: d.class,
113            reason: d.reason,
114        }
115    }
116}
117
118/// Coarse class of an [`HttpError`], from [`HttpError::class`]. Apps map their own error
119/// classes and retry sets from this one table instead of matching statuses themselves.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121#[non_exhaustive]
122pub enum ErrorClass {
123    /// HTTP 401, 403 or 407.
124    Auth,
125    /// HTTP 429. `retry_after` is the parsed `Retry-After` delay, when the server sent one.
126    RateLimit { retry_after: Option<Duration> },
127    /// HTTP 5xx.
128    Server,
129    /// Any other status: 4xx not listed above, or an unfollowed 3xx.
130    Client,
131    /// A configured timeout elapsed, or the server answered HTTP 408 Request Timeout.
132    Timeout,
133    /// Connection, TLS or socket failure, or an exhausted redirect limit.
134    Transport,
135    /// The call was cancelled.
136    Cancelled,
137    /// The outbound address policy refused the destination.
138    AddressDenied,
139    /// The request could not be built, or the response body was malformed or oversized
140    /// (`InvalidRequest`, `Json`, `Stream`, `BodyTooLarge`), `https_only` refused a URL
141    /// (`HttpsRequired`), or a redirect target was refused (`RedirectRefused`).
142    Invalid,
143}
144
145impl HttpError {
146    pub(crate) fn timeout(message: impl Into<String>, phase: Phase) -> Self {
147        HttpError::Timeout {
148            message: message.into(),
149            phase,
150        }
151    }
152
153    pub(crate) fn transport(message: impl Into<String>, phase: Phase) -> Self {
154        HttpError::Transport {
155            message: message.into(),
156            connect_phase: phase == Phase::Connect,
157            phase,
158        }
159    }
160
161    /// The [`Phase`] of a `Timeout` or `Transport` error; `None` for every other kind.
162    pub fn phase(&self) -> Option<Phase> {
163        match self {
164            HttpError::Timeout { phase, .. } | HttpError::Transport { phase, .. } => Some(*phase),
165            _ => None,
166        }
167    }
168
169    /// Re-label a `Timeout` or `Transport` error as happening during a redirect hop. A
170    /// transport error is no longer a pure connect failure, because earlier hops reached servers.
171    pub(crate) fn into_redirect_phase(self) -> Self {
172        match self {
173            HttpError::Timeout { message, .. } => HttpError::timeout(message, Phase::Redirect),
174            HttpError::Transport { message, .. } => HttpError::Transport {
175                message,
176                connect_phase: false,
177                phase: Phase::Redirect,
178            },
179            other => other,
180        }
181    }
182
183    pub fn status(&self) -> Option<u16> {
184        match self {
185            HttpError::Status { status, .. } => Some(*status),
186            _ => None,
187        }
188    }
189
190    /// The error's [`ErrorClass`]. See the README for the full table.
191    pub fn class(&self) -> ErrorClass {
192        match self {
193            HttpError::Status {
194                status,
195                retry_after,
196                ..
197            } => match *status {
198                401 | 403 | 407 => ErrorClass::Auth,
199                408 => ErrorClass::Timeout,
200                429 => ErrorClass::RateLimit {
201                    retry_after: *retry_after,
202                },
203                500..=599 => ErrorClass::Server,
204                _ => ErrorClass::Client,
205            },
206            HttpError::Timeout { .. } => ErrorClass::Timeout,
207            HttpError::Transport { .. } => ErrorClass::Transport,
208            HttpError::Cancelled => ErrorClass::Cancelled,
209            HttpError::SystemProxyUnsupported { .. } => ErrorClass::Invalid,
210            HttpError::AddressDenied { .. } => ErrorClass::AddressDenied,
211            HttpError::HttpsRequired { .. } => ErrorClass::Invalid,
212            HttpError::RedirectRefused { .. } => ErrorClass::Invalid,
213            HttpError::InvalidRequest(_)
214            | HttpError::Json(_)
215            | HttpError::Stream(_)
216            | HttpError::BodyTooLarge { .. } => ErrorClass::Invalid,
217        }
218    }
219}
220
221impl fmt::Display for HttpError {
222    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
223        match self {
224            HttpError::InvalidRequest(m) => write!(f, "invalid request: {m}"),
225            HttpError::Transport { message, .. } => write!(f, "transport error: {message}"),
226            HttpError::Timeout { message, .. } => write!(f, "timeout: {message}"),
227            HttpError::Status {
228                status, attempts, ..
229            } => {
230                write!(f, "HTTP {status} after {attempts} attempt(s)")
231            }
232            HttpError::BodyTooLarge { limit } => write!(f, "response body exceeds {limit} bytes"),
233            HttpError::Json(m) => write!(f, "invalid JSON: {m}"),
234            HttpError::Stream(m) => write!(f, "stream error: {m}"),
235            HttpError::Cancelled => write!(f, "cancelled"),
236            HttpError::SystemProxyUnsupported { element } => write!(
237                f,
238                "the Windows proxy settings cannot be followed exactly by this version \
239                 ({element}); nothing was sent"
240            ),
241            HttpError::AddressDenied { host, reason, .. } => {
242                write!(f, "address denied for host '{host}': {reason}")
243            }
244            HttpError::HttpsRequired {
245                host,
246                scheme,
247                redirect,
248            } => {
249                let what = if *redirect {
250                    "redirect to"
251                } else {
252                    "request to"
253                };
254                write!(
255                    f,
256                    "https required: {what} '{scheme}' URL for host '{host}' refused"
257                )
258            }
259            HttpError::RedirectRefused { host, reason } => {
260                write!(f, "redirect refused for host '{host}': {reason}")
261            }
262        }
263    }
264}
265
266impl std::error::Error for HttpError {}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    fn status(code: u16, retry_after: Option<Duration>) -> HttpError {
273        HttpError::Status {
274            status: code,
275            body: String::new(),
276            retry_after,
277            attempts: 1,
278        }
279    }
280
281    #[test]
282    fn statuses_map_to_their_classes() {
283        for code in [401, 403, 407] {
284            assert_eq!(status(code, None).class(), ErrorClass::Auth, "{code}");
285        }
286        for code in [500, 502, 503, 504, 599] {
287            assert_eq!(status(code, None).class(), ErrorClass::Server, "{code}");
288        }
289        for code in [400, 402, 404, 409, 410, 422, 451, 499, 301, 302] {
290            assert_eq!(status(code, None).class(), ErrorClass::Client, "{code}");
291        }
292    }
293
294    #[test]
295    fn http_408_is_a_timeout_not_a_client_error() {
296        assert_eq!(status(408, None).class(), ErrorClass::Timeout);
297        assert_eq!(status(408, None).status(), Some(408));
298    }
299
300    #[test]
301    fn rate_limit_carries_the_parsed_retry_after() {
302        assert_eq!(
303            status(429, Some(Duration::from_secs(7))).class(),
304            ErrorClass::RateLimit {
305                retry_after: Some(Duration::from_secs(7))
306            }
307        );
308        assert_eq!(
309            status(429, None).class(),
310            ErrorClass::RateLimit { retry_after: None }
311        );
312    }
313
314    #[test]
315    fn non_status_errors_map_to_their_classes() {
316        assert_eq!(
317            HttpError::timeout("x", Phase::Body).class(),
318            ErrorClass::Timeout
319        );
320        for phase in [Phase::Connect, Phase::Headers, Phase::Body, Phase::Redirect] {
321            assert_eq!(
322                HttpError::transport("x", phase).class(),
323                ErrorClass::Transport
324            );
325        }
326        assert_eq!(HttpError::Cancelled.class(), ErrorClass::Cancelled);
327        assert_eq!(
328            HttpError::AddressDenied {
329                host: "h".into(),
330                addr: None,
331                class: None,
332                reason: "r".into()
333            }
334            .class(),
335            ErrorClass::AddressDenied
336        );
337        assert_eq!(
338            HttpError::InvalidRequest("x".into()).class(),
339            ErrorClass::Invalid
340        );
341        assert_eq!(HttpError::Json("x".into()).class(), ErrorClass::Invalid);
342        let refused = HttpError::RedirectRefused {
343            host: "h".into(),
344            reason: RedirectRefusal::UnapprovedOrigin,
345        };
346        assert_eq!(refused.class(), ErrorClass::Invalid);
347        assert_eq!(refused.phase(), None);
348        assert_eq!(
349            refused.to_string(),
350            "redirect refused for host 'h': redirect target origin is not approved"
351        );
352        assert_eq!(HttpError::Stream("x".into()).class(), ErrorClass::Invalid);
353        assert_eq!(
354            HttpError::BodyTooLarge { limit: 1 }.class(),
355            ErrorClass::Invalid
356        );
357    }
358
359    #[test]
360    fn phase_is_carried_by_timeout_and_transport_only() {
361        for phase in [Phase::Connect, Phase::Headers, Phase::Body, Phase::Redirect] {
362            assert_eq!(HttpError::timeout("t", phase).phase(), Some(phase));
363            assert_eq!(HttpError::transport("t", phase).phase(), Some(phase));
364        }
365        assert_eq!(HttpError::Cancelled.phase(), None);
366        assert_eq!(status(500, None).phase(), None);
367        assert_eq!(HttpError::InvalidRequest("x".into()).phase(), None);
368    }
369
370    #[test]
371    fn connect_phase_is_true_exactly_for_the_connect_phase() {
372        for (phase, want) in [
373            (Phase::Connect, true),
374            (Phase::Headers, false),
375            (Phase::Body, false),
376            (Phase::Redirect, false),
377        ] {
378            match HttpError::transport("t", phase) {
379                HttpError::Transport { connect_phase, .. } => assert_eq!(connect_phase, want),
380                other => panic!("{other:?}"),
381            }
382        }
383    }
384
385    #[test]
386    fn redirect_relabel_clears_connect_phase() {
387        match HttpError::transport("t", Phase::Connect).into_redirect_phase() {
388            HttpError::Transport {
389                connect_phase,
390                phase,
391                ..
392            } => {
393                assert!(!connect_phase);
394                assert_eq!(phase, Phase::Redirect);
395            }
396            other => panic!("{other:?}"),
397        }
398        assert_eq!(
399            HttpError::timeout("t", Phase::Headers)
400                .into_redirect_phase()
401                .phase(),
402            Some(Phase::Redirect)
403        );
404        assert!(matches!(
405            HttpError::Cancelled.into_redirect_phase(),
406            HttpError::Cancelled
407        ));
408    }
409
410    #[test]
411    fn phase_names() {
412        assert_eq!(Phase::Connect.as_str(), "connect");
413        assert_eq!(Phase::Headers.as_str(), "headers");
414        assert_eq!(Phase::Body.as_str(), "body");
415        assert_eq!(Phase::Redirect.as_str(), "redirect");
416    }
417}