Skip to main content

git_lfs_transfer/
error.rs

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