git_policy_cli/lfs_server_retry.rs
1//! What:
2//! When a failed `git-lfs push` is tried again: the server-error classification, the delays and the notice.
3//! Why:
4//! The LFS server of a repository can answer HTTP 5xx for a moment, and `git-lfs` treats such an answer as fatal for the
5//! run (`lfshttp/errors.go`: a status above 499 other than 501, 507 and 509 becomes a fatal error), so a push that was
6//! fine a second later fails. Uploads are content addressed, so a second run never sends an object twice. The retry is
7//! bounded twice over: a short fixed list of delays, and a classification from the program's own output that never
8//! retries a refused credential, a missing local object, or any failure that is not a server error.
9//!
10//! In TS you'd write (pseudocode):
11//! ```ts
12//! const delay = isServerError(output) ? delays[retriesDone] : undefined; // undefined: stop and explain
13//! ```
14
15/// The invocation's environment values.
16use super::child_environment::environment_value;
17/// Owned OS text for the environment.
18use std::ffi::OsString;
19/// Delays.
20use std::time::Duration;
21
22/// How long to wait before each retry of a push that failed with a server error: two retries, so three runs at most.
23pub const RETRY_DELAYS: [Duration; 2] = [Duration::from_secs(2), Duration::from_secs(5)];
24
25/// The variable whose comma-separated millisecond list replaces [`RETRY_DELAYS`] (an empty value means no retry); it
26/// exists so a test does not wait for the real delays.
27pub const RETRY_DELAYS_VARIABLE: &str = "CLI_GIT_LFS_RETRY_DELAYS_MILLISECONDS";
28
29/// The most retries a configured list may hold, so a mistyped list cannot make the step unbounded.
30pub const MAX_CONFIGURED_RETRIES: usize = 5;
31
32/// The longest single delay a configured list may hold.
33pub const MAX_CONFIGURED_DELAY: Duration = Duration::from_secs(60);
34
35/// Lines that name a failure retrying cannot fix: refused credentials, a missing object or repository, a refused request,
36/// a corrupt or missing local object, locked files.
37const PERMANENT_MARKERS: [&str; 7] = [
38 "Authorization error",
39 "Repository or object not found",
40 "Client error",
41 "Unprocessable entity",
42 "(missing)",
43 "(corrupt)",
44 "locked files",
45];
46
47/// What `git-lfs` puts before the message of every 5xx answer except 501, 507 and 509 (`errors/types.go`,
48/// `NewFatalError`, used only by `lfshttp/errors.go` for those statuses), whatever the server's body says: a JSON
49/// `message` in the body replaces the `Server error` wording but not this prefix.
50const FATAL_PREFIX: &str = "Fatal error: ";
51
52/// What the message of a 500 answer starts with (`lfshttp/errors.go`, `defaultError`).
53const SERVER_ERROR_500: &str = "Server error: ";
54
55/// What the message of any other retryable 5xx answer holds (`lfshttp/errors.go`, `defaultError`).
56const SERVER_ERROR_OTHER: &str = "Server error ";
57
58/// What precedes the status code in that message.
59const FROM_HTTP: &str = " from HTTP ";
60
61/// Whether the text after `from HTTP ` starts with a 5xx status.
62fn starts_with_server_status(rest: &str) -> bool {
63 let digits: &str = rest.get(..3).unwrap_or_default();
64 return digits.len() == 3 && digits.starts_with('5') && digits.bytes().all(|byte| {
65 return byte.is_ascii_digit();
66 });
67}
68
69/// Whether one line is a server-error message of `git-lfs`.
70fn line_is_server_error(line: &str) -> bool {
71 if line.contains(FATAL_PREFIX) || line.contains(SERVER_ERROR_500) {
72 return true;
73 }
74 return line.contains(SERVER_ERROR_OTHER)
75 && line.split(FROM_HTTP).skip(1).any(starts_with_server_status);
76}
77
78/// What:
79/// Whether the output of a failed `git-lfs push` says the LFS server answered with a retryable server error, and says
80/// nothing that retrying cannot fix.
81/// Why:
82/// A positive match on the program's own words (`Server error: <url>` for 500, `Server error <url> from HTTP 5xx` for the
83/// other retryable 5xx) keeps every unknown failure terminal. A line naming a refused credential, a missing object or a
84/// locked file anywhere in the output makes the whole failure terminal, because a retry would meet it again.
85/// The words are `git-lfs`'s English messages; a translated message is not retried, which fails toward the explanation.
86///
87/// In TS you'd write (pseudocode):
88/// ```ts
89/// lines.some(isServerErrorLine) && !lines.some((line) => PERMANENT_MARKERS.some((marker) => line.includes(marker)))
90/// ```
91pub fn is_server_error(output: &str) -> bool {
92 let permanent: bool = output.lines().any(|line| {
93 return PERMANENT_MARKERS.iter().any(|marker| {
94 return line.contains(marker);
95 });
96 });
97 return !permanent && output.lines().any(line_is_server_error);
98}
99
100/// What:
101/// The delays to use: the configured millisecond list when the variable is present and well formed, else
102/// [`RETRY_DELAYS`].
103/// Why:
104/// A test injects short delays through the environment the wrapper was started with. A list is cut to
105/// [`MAX_CONFIGURED_RETRIES`] entries of at most [`MAX_CONFIGURED_DELAY`] each, and a value that is not a list of
106/// whole numbers is ignored, so the step stays bounded whatever the variable holds.
107///
108/// In TS you'd write (pseudocode):
109/// ```ts
110/// function retryDelays(env): number[]; // milliseconds
111/// ```
112pub fn retry_delays(environment: &[(OsString, OsString)]) -> Vec<Duration> {
113 let Some(value) = environment_value(environment, RETRY_DELAYS_VARIABLE) else {
114 return RETRY_DELAYS.to_vec();
115 };
116 let text: String = value.to_string_lossy().trim().to_owned();
117 if text.is_empty() {
118 return Vec::new();
119 }
120 let parsed: Option<Vec<Duration>> = text
121 .split(',')
122 .map(|entry| {
123 return entry.trim().parse::<u64>().ok().map(Duration::from_millis);
124 })
125 .collect();
126 return parsed.map_or_else(
127 || {
128 return RETRY_DELAYS.to_vec();
129 },
130 |delays| {
131 return delays
132 .into_iter()
133 .take(MAX_CONFIGURED_RETRIES)
134 .map(|delay| {
135 return delay.min(MAX_CONFIGURED_DELAY);
136 })
137 .collect();
138 },
139 );
140}
141
142/// What:
143/// The delay before the next run of a push that just failed with `output`, or nothing when it is not to be retried.
144/// Why:
145/// `retries_done` runs have already been repeated; the list is the whole allowance.
146///
147/// In TS you'd write (pseudocode):
148/// ```ts
149/// function nextDelay(output: string, retriesDone: number, delays: number[]): number | undefined;
150/// ```
151pub fn next_delay(output: &str, retries_done: usize, delays: &[Duration]) -> Option<Duration> {
152 let delay: Duration = delays.get(retries_done).copied()?;
153 return is_server_error(output).then_some(delay);
154}
155
156/// What:
157/// The line printed before a retry.
158/// Why:
159/// The caller sees why the run is repeated, how long it waits and how many repeats are left.
160///
161/// In TS you'd write (pseudocode):
162/// ```ts
163/// `cli-git: git-lfs push failed with a server error; trying again in ${seconds} seconds (retry ${n} of ${total}).`
164/// ```
165pub fn retry_notice(delay: Duration, retries_done: usize, total: usize) -> String {
166 return format!(
167 "cli-git: `git-lfs push` failed with a server error, so it is run again in {:.1} seconds (retry {} of {total}); \
168 objects the server already has are not sent twice.\n",
169 delay.as_secs_f64(),
170 retries_done + 1,
171 );
172}
173
174/// The classification and the delays are controlled in the test module.
175#[cfg(test)]
176#[path = "lfs_server_retry_tests.rs"]
177mod tests;