1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
//! Transfer-level errors, classified by whether retrying could possibly help.
use std::time::Duration;
#[derive(Debug, thiserror::Error)]
pub enum TransferError {
/// Connection reset, DNS failure, TLS handshake failure, network down.
#[error("network error: {0}")]
Network(String),
/// No bytes arrived within the read timeout.
#[error("timed out after {0:?} with no data")]
Timeout(Duration),
/// The server answered, unhappily.
#[error("server returned HTTP {status}")]
Status {
status: u16,
retry_after: Option<Duration>,
},
/// Validators say we are no longer looking at the same bytes. Retrying
/// cannot fix this and continuing would corrupt the file.
#[error("remote resource changed: {0}")]
RemoteChanged(String),
/// The server broke the HTTP contract — ignored `Range`, returned a
/// `Content-Range` that does not match what we asked for, sent more bytes
/// than it promised.
#[error("protocol violation: {0}")]
Protocol(String),
/// Local disk problem.
#[error("write failed: {0}")]
Io(String),
/// Graceful shutdown, not really a failure.
#[error("cancelled")]
Cancelled,
}
impl TransferError {
/// PRD §14: retry connection resets, timeouts, DNS failures, 408, 429, 5xx.
/// Nothing else.
pub fn is_retryable(&self) -> bool {
match self {
TransferError::Network(_) | TransferError::Timeout(_) => true,
TransferError::Status { status, .. } => {
*status == 408 || *status == 429 || (500..600).contains(status)
}
// A truthful server that ignored our Range will ignore it again;
// the engine handles that by falling back to sequential, not by
// retrying blindly.
TransferError::Protocol(_)
| TransferError::RemoteChanged(_)
| TransferError::Io(_)
| TransferError::Cancelled => false,
}
}
/// Server-suggested delay, honoured when present (PRD §14).
pub fn retry_after(&self) -> Option<Duration> {
match self {
TransferError::Status { retry_after, .. } => *retry_after,
_ => None,
}
}
pub fn from_reqwest(err: &reqwest::Error) -> Self {
// Everything reqwest reports that is not a timeout — connect failures,
// TLS errors, resets, truncated bodies — is a transient network fault as
// far as our retry policy is concerned.
if err.is_timeout() {
TransferError::Timeout(Duration::ZERO)
} else {
TransferError::Network(sanitize_reqwest(err))
}
}
}
/// `reqwest`'s `Display` includes the full URL, which may carry a signed token
/// or basic-auth userinfo. Strip it before the message can reach a log.
fn sanitize_reqwest(err: &reqwest::Error) -> String {
let mut msg = err.to_string();
if let Some(url) = err.url() {
let redacted = crate::fmt::short_url(url.as_str());
msg = msg.replace(url.as_str(), &redacted);
}
// Chain the source for context, minus URLs.
let mut source = std::error::Error::source(err);
let mut depth = 0;
while let Some(s) = source {
if depth >= 3 {
break;
}
msg.push_str(": ");
msg.push_str(&s.to_string());
source = s.source();
depth += 1;
}
msg
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn retryable_classification() {
assert!(TransferError::Network("reset".into()).is_retryable());
assert!(TransferError::Timeout(Duration::from_secs(1)).is_retryable());
for s in [408, 429, 500, 502, 503, 504] {
assert!(
TransferError::Status {
status: s,
retry_after: None
}
.is_retryable(),
"{s} should retry"
);
}
for s in [400, 401, 403, 404, 416] {
assert!(
!TransferError::Status {
status: s,
retry_after: None
}
.is_retryable(),
"{s} should not retry"
);
}
assert!(!TransferError::RemoteChanged("etag".into()).is_retryable());
assert!(!TransferError::Protocol("ignored range".into()).is_retryable());
assert!(!TransferError::Cancelled.is_retryable());
}
}