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