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}