Skip to main content

git_lfs_transfer/
error.rs

1use git_lfs_api::{ApiError, ObjectError};
2use git_lfs_pointer::OidParseError;
3use git_lfs_store::StoreError;
4
5/// Why a per-object transfer failed.
6///
7/// Errors with `is_retryable() == true` are retried by the queue up to
8/// [`TransferConfig::max_attempts`](crate::TransferConfig::max_attempts);
9/// everything else fails fast.
10#[derive(Debug, thiserror::Error)]
11pub enum TransferError {
12    /// The batch endpoint returned a per-object error (404, 410, 422, …).
13    /// Not retryable: the server has already classified the object.
14    #[error("server error for object: {} ({})", .0.message, .0.code)]
15    ServerObject(ObjectError),
16
17    /// The batch response listed the object with neither `actions` nor
18    /// `error` for a download — the spec forbids this, but real servers do
19    /// it occasionally; we surface it instead of panicking.
20    #[error("server returned no download action for object")]
21    NoDownloadAction,
22
23    /// The action URL responded with a non-success status. The URL is
24    /// embedded in the [`Display`](std::fmt::Display) impl so users can
25    /// see *which* endpoint failed (in particular, what `insteadOf`
26    /// rewriting did to the original batch URL — see t-pull's
27    /// `pull with invalid insteadof`).
28    #[error("{}", format_action_status(*.status, .url))]
29    ActionStatus { status: u16, url: String },
30
31    /// HTTP transport failure (connection reset, TLS error, …).
32    /// Retryable.
33    #[error("http error: {0}")]
34    Http(#[from] reqwest::Error),
35
36    /// Local I/O while reading the object file (uploads) or the staging
37    /// tempfile (downloads).
38    #[error("local io error: {0}")]
39    Io(#[from] std::io::Error),
40
41    /// The local store rejected the bytes — most importantly, hash mismatch
42    /// after a download. Not retryable per attempt: the bytes the server
43    /// gave us did not hash to what they promised.
44    #[error("store error: {0}")]
45    Store(#[from] StoreError),
46
47    /// The OID returned by the server is not valid hex.
48    #[error("invalid oid from server: {0}")]
49    InvalidOid(#[from] OidParseError),
50
51    /// The batch response advertised a `hash_algo` we don't implement.
52    /// Per the spec the only required value is `sha256`; anything else
53    /// would mean we'd need to recompute every OID under a different
54    /// digest before we could trust the server's actions.
55    #[error("unsupported hash algorithm: {0}")]
56    UnsupportedHashAlgo(String),
57}
58
59/// Format the action-URL error message to match upstream's
60/// `lfshttp.defaultError` strings — the test suite greps these
61/// verbatim (e.g. t-pull's `pull with invalid insteadof`).
62fn format_action_status(status: u16, url: &str) -> String {
63    let prefix = match status {
64        400 => "Client error:",
65        401 | 403 => "Authorization error:",
66        404 => "Repository or object not found:",
67        422 => "Unprocessable entity:",
68        429 => "Rate limit exceeded:",
69        500 => "Server error:",
70        501 => "Not Implemented:",
71        503 => "LFS is temporarily unavailable:",
72        507 => "Insufficient server storage:",
73        509 => "Bandwidth limit exceeded:",
74        _ if status < 500 => return format!("LFS: Client error {url} from HTTP {status}"),
75        _ => return format!("LFS: Server error {url} from HTTP {status}"),
76    };
77    format!("LFS: {prefix} {url}")
78}
79
80impl TransferError {
81    /// Worth another attempt? Network blips and 5xx are retryable; spec
82    /// violations and hash mismatches are not.
83    pub fn is_retryable(&self) -> bool {
84        match self {
85            TransferError::Http(e) => {
86                // reqwest::Error doesn't expose enough to be precise — treat
87                // any non-decode transport error as retryable. Hash mismatch
88                // surfaces via Store, not Http.
89                !e.is_decode() && !e.is_builder()
90            }
91            TransferError::ActionStatus { status, .. } => {
92                matches!(status, 408 | 429 | 500..=599)
93            }
94            TransferError::Io(_) => true,
95            TransferError::ServerObject(_)
96            | TransferError::NoDownloadAction
97            | TransferError::Store(_)
98            | TransferError::InvalidOid(_)
99            | TransferError::UnsupportedHashAlgo(_) => false,
100        }
101    }
102}
103
104impl From<ApiError> for TransferError {
105    fn from(value: ApiError) -> Self {
106        match value {
107            ApiError::Transport(e) => TransferError::Http(e),
108            other => {
109                // Typed Status / Decode / Url. Wrap as Io with the original
110                // message — this only fires on the batch call, which is
111                // upstream of per-object retry, so we never inspect this.
112                TransferError::Io(std::io::Error::other(other.to_string()))
113            }
114        }
115    }
116}
117
118/// Aggregate outcome of a transfer batch.
119#[derive(Debug, Default)]
120pub struct Report {
121    /// OIDs of objects that completed successfully.
122    pub succeeded: Vec<String>,
123    /// OIDs and reasons for objects that ultimately failed.
124    pub failed: Vec<(String, TransferError)>,
125}
126
127impl Report {
128    pub fn is_complete_success(&self) -> bool {
129        self.failed.is_empty()
130    }
131}