1use std::fmt;
2use std::net::IpAddr;
3use std::time::Duration;
4
5use crate::netpolicy::{AddressClass, AddressDenial, RedirectRefusal};
6
7pub const MAX_ERROR_BODY_BYTES: usize = 64 * 1024;
9
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13#[non_exhaustive]
14pub enum Phase {
15 Connect,
17 Headers,
19 Body,
21 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 InvalidRequest(String),
41 Transport {
46 message: String,
47 connect_phase: bool,
48 phase: Phase,
49 },
50 Timeout { message: String, phase: Phase },
52 Status {
54 status: u16,
55 body: String,
56 retry_after: Option<Duration>,
57 attempts: u32,
58 },
59 BodyTooLarge { limit: usize },
61 Json(String),
63 Stream(String),
65 Cancelled,
67 SystemProxyUnsupported { element: String },
78 AddressDenied {
83 host: String,
84 addr: Option<IpAddr>,
85 class: Option<AddressClass>,
86 reason: String,
87 },
88 HttpsRequired {
92 host: String,
93 scheme: String,
94 redirect: bool,
95 },
96 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121#[non_exhaustive]
122pub enum ErrorClass {
123 Auth,
125 RateLimit { retry_after: Option<Duration> },
127 Server,
129 Client,
131 Timeout,
133 Transport,
135 Cancelled,
137 AddressDenied,
139 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 pub fn phase(&self) -> Option<Phase> {
163 match self {
164 HttpError::Timeout { phase, .. } | HttpError::Transport { phase, .. } => Some(*phase),
165 _ => None,
166 }
167 }
168
169 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 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}