1use std::fmt;
2use std::net::IpAddr;
3use std::time::Duration;
4
5use crate::netpolicy::{AddressClass, AddressDenial};
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}
97
98impl From<AddressDenial> for HttpError {
99 fn from(d: AddressDenial) -> Self {
100 HttpError::AddressDenied {
101 host: d.host,
102 addr: d.addr,
103 class: d.class,
104 reason: d.reason,
105 }
106 }
107}
108
109#[derive(Debug, Clone, Copy, PartialEq, Eq)]
112#[non_exhaustive]
113pub enum ErrorClass {
114 Auth,
116 RateLimit { retry_after: Option<Duration> },
118 Server,
120 Client,
122 Timeout,
124 Transport,
126 Cancelled,
128 AddressDenied,
130 Invalid,
134}
135
136impl HttpError {
137 pub(crate) fn timeout(message: impl Into<String>, phase: Phase) -> Self {
138 HttpError::Timeout {
139 message: message.into(),
140 phase,
141 }
142 }
143
144 pub(crate) fn transport(message: impl Into<String>, phase: Phase) -> Self {
145 HttpError::Transport {
146 message: message.into(),
147 connect_phase: phase == Phase::Connect,
148 phase,
149 }
150 }
151
152 pub fn phase(&self) -> Option<Phase> {
154 match self {
155 HttpError::Timeout { phase, .. } | HttpError::Transport { phase, .. } => Some(*phase),
156 _ => None,
157 }
158 }
159
160 pub(crate) fn into_redirect_phase(self) -> Self {
163 match self {
164 HttpError::Timeout { message, .. } => HttpError::timeout(message, Phase::Redirect),
165 HttpError::Transport { message, .. } => HttpError::Transport {
166 message,
167 connect_phase: false,
168 phase: Phase::Redirect,
169 },
170 other => other,
171 }
172 }
173
174 pub fn status(&self) -> Option<u16> {
175 match self {
176 HttpError::Status { status, .. } => Some(*status),
177 _ => None,
178 }
179 }
180
181 pub fn class(&self) -> ErrorClass {
183 match self {
184 HttpError::Status {
185 status,
186 retry_after,
187 ..
188 } => match *status {
189 401 | 403 | 407 => ErrorClass::Auth,
190 408 => ErrorClass::Timeout,
191 429 => ErrorClass::RateLimit {
192 retry_after: *retry_after,
193 },
194 500..=599 => ErrorClass::Server,
195 _ => ErrorClass::Client,
196 },
197 HttpError::Timeout { .. } => ErrorClass::Timeout,
198 HttpError::Transport { .. } => ErrorClass::Transport,
199 HttpError::Cancelled => ErrorClass::Cancelled,
200 HttpError::SystemProxyUnsupported { .. } => ErrorClass::Invalid,
201 HttpError::AddressDenied { .. } => ErrorClass::AddressDenied,
202 HttpError::HttpsRequired { .. } => ErrorClass::Invalid,
203 HttpError::InvalidRequest(_)
204 | HttpError::Json(_)
205 | HttpError::Stream(_)
206 | HttpError::BodyTooLarge { .. } => ErrorClass::Invalid,
207 }
208 }
209}
210
211impl fmt::Display for HttpError {
212 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
213 match self {
214 HttpError::InvalidRequest(m) => write!(f, "invalid request: {m}"),
215 HttpError::Transport { message, .. } => write!(f, "transport error: {message}"),
216 HttpError::Timeout { message, .. } => write!(f, "timeout: {message}"),
217 HttpError::Status {
218 status, attempts, ..
219 } => {
220 write!(f, "HTTP {status} after {attempts} attempt(s)")
221 }
222 HttpError::BodyTooLarge { limit } => write!(f, "response body exceeds {limit} bytes"),
223 HttpError::Json(m) => write!(f, "invalid JSON: {m}"),
224 HttpError::Stream(m) => write!(f, "stream error: {m}"),
225 HttpError::Cancelled => write!(f, "cancelled"),
226 HttpError::SystemProxyUnsupported { element } => write!(
227 f,
228 "the Windows proxy settings cannot be followed exactly by this version \
229 ({element}); nothing was sent"
230 ),
231 HttpError::AddressDenied { host, reason, .. } => {
232 write!(f, "address denied for host '{host}': {reason}")
233 }
234 HttpError::HttpsRequired {
235 host,
236 scheme,
237 redirect,
238 } => {
239 let what = if *redirect {
240 "redirect to"
241 } else {
242 "request to"
243 };
244 write!(
245 f,
246 "https required: {what} '{scheme}' URL for host '{host}' refused"
247 )
248 }
249 }
250 }
251}
252
253impl std::error::Error for HttpError {}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258
259 fn status(code: u16, retry_after: Option<Duration>) -> HttpError {
260 HttpError::Status {
261 status: code,
262 body: String::new(),
263 retry_after,
264 attempts: 1,
265 }
266 }
267
268 #[test]
269 fn statuses_map_to_their_classes() {
270 for code in [401, 403, 407] {
271 assert_eq!(status(code, None).class(), ErrorClass::Auth, "{code}");
272 }
273 for code in [500, 502, 503, 504, 599] {
274 assert_eq!(status(code, None).class(), ErrorClass::Server, "{code}");
275 }
276 for code in [400, 402, 404, 409, 410, 422, 451, 499, 301, 302] {
277 assert_eq!(status(code, None).class(), ErrorClass::Client, "{code}");
278 }
279 }
280
281 #[test]
282 fn http_408_is_a_timeout_not_a_client_error() {
283 assert_eq!(status(408, None).class(), ErrorClass::Timeout);
284 assert_eq!(status(408, None).status(), Some(408));
285 }
286
287 #[test]
288 fn rate_limit_carries_the_parsed_retry_after() {
289 assert_eq!(
290 status(429, Some(Duration::from_secs(7))).class(),
291 ErrorClass::RateLimit {
292 retry_after: Some(Duration::from_secs(7))
293 }
294 );
295 assert_eq!(
296 status(429, None).class(),
297 ErrorClass::RateLimit { retry_after: None }
298 );
299 }
300
301 #[test]
302 fn non_status_errors_map_to_their_classes() {
303 assert_eq!(
304 HttpError::timeout("x", Phase::Body).class(),
305 ErrorClass::Timeout
306 );
307 for phase in [Phase::Connect, Phase::Headers, Phase::Body, Phase::Redirect] {
308 assert_eq!(
309 HttpError::transport("x", phase).class(),
310 ErrorClass::Transport
311 );
312 }
313 assert_eq!(HttpError::Cancelled.class(), ErrorClass::Cancelled);
314 assert_eq!(
315 HttpError::AddressDenied {
316 host: "h".into(),
317 addr: None,
318 class: None,
319 reason: "r".into()
320 }
321 .class(),
322 ErrorClass::AddressDenied
323 );
324 assert_eq!(
325 HttpError::InvalidRequest("x".into()).class(),
326 ErrorClass::Invalid
327 );
328 assert_eq!(HttpError::Json("x".into()).class(), ErrorClass::Invalid);
329 assert_eq!(HttpError::Stream("x".into()).class(), ErrorClass::Invalid);
330 assert_eq!(
331 HttpError::BodyTooLarge { limit: 1 }.class(),
332 ErrorClass::Invalid
333 );
334 }
335
336 #[test]
337 fn phase_is_carried_by_timeout_and_transport_only() {
338 for phase in [Phase::Connect, Phase::Headers, Phase::Body, Phase::Redirect] {
339 assert_eq!(HttpError::timeout("t", phase).phase(), Some(phase));
340 assert_eq!(HttpError::transport("t", phase).phase(), Some(phase));
341 }
342 assert_eq!(HttpError::Cancelled.phase(), None);
343 assert_eq!(status(500, None).phase(), None);
344 assert_eq!(HttpError::InvalidRequest("x".into()).phase(), None);
345 }
346
347 #[test]
348 fn connect_phase_is_true_exactly_for_the_connect_phase() {
349 for (phase, want) in [
350 (Phase::Connect, true),
351 (Phase::Headers, false),
352 (Phase::Body, false),
353 (Phase::Redirect, false),
354 ] {
355 match HttpError::transport("t", phase) {
356 HttpError::Transport { connect_phase, .. } => assert_eq!(connect_phase, want),
357 other => panic!("{other:?}"),
358 }
359 }
360 }
361
362 #[test]
363 fn redirect_relabel_clears_connect_phase() {
364 match HttpError::transport("t", Phase::Connect).into_redirect_phase() {
365 HttpError::Transport {
366 connect_phase,
367 phase,
368 ..
369 } => {
370 assert!(!connect_phase);
371 assert_eq!(phase, Phase::Redirect);
372 }
373 other => panic!("{other:?}"),
374 }
375 assert_eq!(
376 HttpError::timeout("t", Phase::Headers)
377 .into_redirect_phase()
378 .phase(),
379 Some(Phase::Redirect)
380 );
381 assert!(matches!(
382 HttpError::Cancelled.into_redirect_phase(),
383 HttpError::Cancelled
384 ));
385 }
386
387 #[test]
388 fn phase_names() {
389 assert_eq!(Phase::Connect.as_str(), "connect");
390 assert_eq!(Phase::Headers.as_str(), "headers");
391 assert_eq!(Phase::Body.as_str(), "body");
392 assert_eq!(Phase::Redirect.as_str(), "redirect");
393 }
394}