Skip to main content

rget/
error.rs

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