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}