Skip to main content

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;