Coverage Report

Created: 2026-11-13 23:29

next uncovered line (L), next uncovered region (R), next uncovered branch (B)
/build/source/nativelink-util/src/retry.rs
Line
Count
Source
1
// Copyright 2024 Trace Machina, Inc. All rights reserved.
2
//
3
// Licensed under the Functional Source License, Version 1.1, Apache 2.0 Future License (the "License");
4
// you may not use this file except in compliance with the License.
5
// You may obtain a copy of the License at
6
//
7
//    See LICENSE file for details
8
//
9
// Unless required by applicable law or agreed to in writing, software
10
// distributed under the License is distributed on an "AS IS" BASIS,
11
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
// See the License for the specific language governing permissions and
13
// limitations under the License.
14
15
use core::pin::Pin;
16
use core::time::Duration;
17
use std::sync::Arc;
18
19
use futures::future::Future;
20
use futures::stream::StreamExt;
21
use nativelink_config::stores::{ErrorCode, Retry};
22
use nativelink_error::{Code, Error, make_err};
23
use tracing::{error, info, warn};
24
25
use crate::metrics::record_retry_outcome;
26
27
struct ExponentialBackoff {
28
    current: Duration,
29
}
30
31
impl ExponentialBackoff {
32
476
    const fn new(base: Duration) -> Self {
33
476
        Self { current: base }
34
476
    }
35
}
36
37
impl Iterator for ExponentialBackoff {
38
    type Item = Duration;
39
40
33
    fn next(&mut self) -> Option<Duration> {
41
33
        self.current *= 2;
42
33
        Some(self.current)
43
33
    }
44
}
45
46
type SleepFn = Arc<dyn Fn(Duration) -> Pin<Box<dyn Future<Output = ()> + Send>> + Sync + Send>;
47
pub(crate) type JitterFn = Arc<dyn Fn(Duration) -> Duration + Send + Sync>;
48
49
#[derive(PartialEq, Eq, Debug)]
50
pub enum RetryResult<T> {
51
    Ok(T),
52
    Retry(Error),
53
    /// Retryable, and the server asked for at least this long before the
54
    /// next attempt. Within the retrier's throttle budget the wait is taken
55
    /// as is and spends no attempt; past it the backoff still applies when
56
    /// it is longer and the attempt counts.
57
    RetryAfter(Error, Duration),
58
    Err(Error),
59
}
60
61
/// What became of an operation, or one attempt of it, under a [`Retrier`].
62
/// Recorded as `retry.outcomes`, labeled with the retrier's client.
63
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
64
pub enum RetryOutcome {
65
    /// An attempt failed with a retryable error and another follows. A
66
    /// throttle waited out within the throttle budget counts, though it
67
    /// spends no attempt.
68
    Retried,
69
    /// The operation succeeded after at least one retry.
70
    Recovered,
71
    /// The operation failed with a retryable error and the retries ran out.
72
    Exhausted,
73
    /// The operation failed with an error that is never retried. `NotFound`
74
    /// is left out: on a store it is the ordinary miss.
75
    Permanent,
76
}
77
78
impl RetryOutcome {
79
128
    const fn as_str(self) -> &'static str {
80
128
        match self {
81
75
            Self::Retried => "retried",
82
29
            Self::Recovered => "recovered",
83
23
            Self::Exhausted => "exhausted",
84
1
            Self::Permanent => "permanent",
85
        }
86
128
    }
87
}
88
89
type OutcomeFn = Arc<dyn Fn(RetryOutcome) + Send + Sync>;
90
91
/// Class used to retry a job with a sleep function in between each retry.
92
#[derive(Clone)]
93
pub struct Retrier {
94
    sleep_fn: SleepFn,
95
    jitter_fn: JitterFn,
96
    config: Retry,
97
    client: &'static str,
98
    outcome_fn: Option<OutcomeFn>,
99
    /// How long one `retry` call may spend on `RetryAfter` waits before
100
    /// they start spending attempts. Zero means they always do.
101
    throttle_budget: Duration,
102
    /// Longest a throttle wait may grow to. Set, each consecutive throttle
103
    /// doubles the wait the store asked for, capped here, and jitters it.
104
    /// Unset, the wait is what was asked.
105
    throttle_max_wait: Option<Duration>,
106
}
107
108
impl core::fmt::Debug for Retrier {
109
0
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
110
0
        f.debug_struct("Retrier")
111
0
            .field("config", &self.config)
112
0
            .field("client", &self.client)
113
0
            .finish_non_exhaustive()
114
0
    }
115
}
116
117
3
const fn to_error_code(code: Code) -> ErrorCode {
118
3
    match code {
119
0
        Code::Cancelled => ErrorCode::Cancelled,
120
0
        Code::InvalidArgument => ErrorCode::InvalidArgument,
121
0
        Code::DeadlineExceeded => ErrorCode::DeadlineExceeded,
122
3
        Code::NotFound => ErrorCode::NotFound,
123
0
        Code::AlreadyExists => ErrorCode::AlreadyExists,
124
0
        Code::PermissionDenied => ErrorCode::PermissionDenied,
125
0
        Code::ResourceExhausted => ErrorCode::ResourceExhausted,
126
0
        Code::FailedPrecondition => ErrorCode::FailedPrecondition,
127
0
        Code::Aborted => ErrorCode::Aborted,
128
0
        Code::OutOfRange => ErrorCode::OutOfRange,
129
0
        Code::Unimplemented => ErrorCode::Unimplemented,
130
0
        Code::Internal => ErrorCode::Internal,
131
0
        Code::Unavailable => ErrorCode::Unavailable,
132
0
        Code::DataLoss => ErrorCode::DataLoss,
133
0
        Code::Unauthenticated => ErrorCode::Unauthenticated,
134
0
        _ => ErrorCode::Unknown,
135
    }
136
3
}
137
138
impl Retrier {
139
0
    pub fn new(sleep_fn: SleepFn, jitter_fn: JitterFn, config: Retry) -> Self {
140
0
        Self {
141
0
            sleep_fn,
142
0
            jitter_fn,
143
0
            config,
144
0
            client: "unknown",
145
0
            outcome_fn: None,
146
0
            throttle_budget: Duration::ZERO,
147
0
            throttle_max_wait: None,
148
0
        }
149
0
    }
150
151
    /// Names what this retrier retries for (`azure`, `s3`, `grpc_store`,
152
    /// ...), the `retry.client` label on its `retry.outcomes`.
153
    #[must_use]
154
246
    pub const fn with_client(mut self, client: &'static str) -> Self {
155
246
        self.client = client;
156
246
        self
157
246
    }
158
159
    /// Also hands every outcome to `outcome_fn`, after it is recorded as a
160
    /// metric. For tests, which cannot read the meter.
161
    #[must_use]
162
6
    pub fn with_outcome_fn(mut self, outcome_fn: OutcomeFn) -> Self {
163
6
        self.outcome_fn = Some(outcome_fn);
164
6
        self
165
6
    }
166
167
    /// Lets one `retry` call spend up to `budget` waiting on `RetryAfter`
168
    /// results without counting those tries as attempts.
169
    #[must_use]
170
0
    pub const fn with_throttle_budget(mut self, budget: Duration) -> Self {
171
0
        self.throttle_budget = budget;
172
0
        self
173
0
    }
174
175
    /// Lets consecutive throttles wait longer each time. The first throttle
176
    /// in a row waits what the server asked, the second twice that, the
177
    /// third four times, and so on; `max_wait` is the ceiling that growth
178
    /// stops at, not a wait of its own. The grown wait is spread upward by
179
    /// the jitter and never less than what was asked. Growth applies to the
180
    /// waits the throttle budget pays for; once the budget is spent the
181
    /// backoff ladder takes over and waits at least the plain ask, so the
182
    /// ladder's total stays what the retry config says. A store that is
183
    /// told to slow down with no `Retry-After` otherwise has every client
184
    /// retry in lockstep at the configured floor.
185
    #[must_use]
186
66
    pub const fn with_throttle_max_wait(mut self, max_wait: Duration) -> Self {
187
66
        self.throttle_max_wait = Some(max_wait);
188
66
        self
189
66
    }
190
191
    /// The wait for the `n`-th consecutive throttle whose server asked for
192
    /// `asked`: `asked` itself for the first, doubled once per throttle
193
    /// before it, capped at the configured ceiling, then jittered.
194
48
    fn throttle_wait(&self, asked: Duration, throttles_in_a_row: u32) -> Duration {
195
48
        let Some(
max_wait38
) = self.throttle_max_wait else {
196
10
            return asked;
197
        };
198
        // One doubling per earlier throttle in the row; past 31 the
199
        // multiplier would overflow and the ceiling applies anyway.
200
38
        let doublings = throttles_in_a_row.saturating_sub(1).min(31);
201
38
        let grown = asked.saturating_mul(1 << doublings).min(max_wait);
202
        // `Retry::make_jitter_fn` spreads a delay either way, and on the
203
        // first throttle half of every fleet would land at or below the
204
        // ask and retry in lockstep. A low result is mirrored above the
205
        // grown wait instead, so every step spreads upward only; a jitter
206
        // that zeroes delays (as tests install) leaves the grown wait.
207
38
        let jittered = (self.jitter_fn)(grown);
208
38
        if jittered.is_zero() {
209
21
            grown
210
        } else {
211
17
            grown + jittered.abs_diff(grown)
212
        }
213
48
    }
214
215
128
    fn report(&self, outcome: RetryOutcome) {
216
128
        record_retry_outcome(self.client, outcome.as_str());
217
128
        if let Some(
outcome_fn10
) = &self.outcome_fn {
218
10
            outcome_fn(outcome);
219
118
        }
220
128
    }
221
222
    /// This should only return true if the error code should be interpreted as
223
    /// temporary.
224
105
    fn should_retry(&self, code: Code) -> bool {
225
105
        if let Some(
retry_codes3
) = &self.config.retry_on_errors {
226
3
            retry_codes.contains(&to_error_code(code))
227
        } else {
228
            // Match is intentionally exhaustive: if a new `Code` variant is added,
229
            // the compiler forces a decision here. New codes default to permanent
230
            // (no retry) rather than silently being retried.
231
102
            match code {
232
                Code::Cancelled
233
                | Code::Unknown
234
                | Code::DeadlineExceeded
235
                | Code::ResourceExhausted
236
                | Code::Aborted
237
                | Code::Internal
238
                | Code::Unavailable
239
95
                | Code::DataLoss => true,
240
                Code::Ok
241
                | Code::InvalidArgument
242
                | Code::NotFound
243
                | Code::AlreadyExists
244
                | Code::PermissionDenied
245
                | Code::FailedPrecondition
246
                | Code::OutOfRange
247
                | Code::Unimplemented
248
7
                | Code::Unauthenticated => false,
249
            }
250
        }
251
105
    }
252
253
476
    fn get_retry_config(&self) -> impl Iterator<Item = Duration> + '_ {
254
476
        ExponentialBackoff::new(Duration::from_secs_f32(self.config.delay))
255
476
            .map(|d| 
(self.jitter_fn)33
(
d33
))
256
476
            .take(self.config.max_retries) // Remember this is number of retries, so will run max_retries + 1.
257
476
    }
258
259
    #[expect(
260
        clippy::manual_async_fn,
261
        reason = "making an `async fn` results in a potential compiler bug in seemingly unrelated \
262
            code"
263
    )]
264
476
    pub fn retry<'a, T: Send>(
265
476
        &'a self,
266
476
        operation: impl futures::stream::Stream<Item = RetryResult<T>> + Send + 'a,
267
476
    ) -> impl Future<Output = Result<T, Error>> + Send + 'a {
268
476
        async move {
269
476
            let mut iter = self.get_retry_config();
270
476
            tokio::pin!(operation);
271
476
            let mut attempt = 0;
272
476
            let mut throttled = Duration::ZERO;
273
476
            let mut throttles_in_a_row: u32 = 0;
274
            loop {
275
551
                attempt += 1;
276
551
                let (
err105
,
min_wait105
,
is_throttle105
) = match operation.next().await {
277
                    None => {
278
0
                        return Err(make_err!(
279
0
                            Code::Internal,
280
0
                            "Retry stream ended abruptly on attempt {attempt}",
281
0
                        ));
282
                    }
283
423
                    Some(RetryResult::Ok(value)) => {
284
                        // A throttle within the budget is a retry too,
285
                        // though it leaves `attempt` where it was.
286
423
                        if attempt > 1 || 
!throttled.is_zero()409
{
287
29
                            self.report(RetryOutcome::Recovered);
288
394
                        }
289
423
                        return Ok(value);
290
                    }
291
22
                    Some(RetryResult::Err(e)) => {
292
22
                        return Err(e.append(format!("On attempt {attempt}")));
293
                    }
294
52
                    Some(RetryResult::Retry(err)) => {
295
52
                        throttles_in_a_row = 0;
296
52
                        (err, Duration::ZERO, false)
297
                    }
298
53
                    Some(RetryResult::RetryAfter(err, min_wait)) => {
299
53
                        throttles_in_a_row = throttles_in_a_row.saturating_add(1);
300
53
                        (err, min_wait, true)
301
                    }
302
                };
303
105
                if !self.should_retry(err.code) {
304
7
                    if err.code == Code::NotFound {
305
6
                        info!(?err, "Not found, not retrying");
306
                    } else {
307
1
                        self.report(RetryOutcome::Permanent);
308
1
                        error!(?attempt, ?err, "Not retrying permanent error");
309
                    }
310
7
                    return Err(err);
311
98
                }
312
                // A throttle within the budget waits what was asked and is
313
                // not an attempt: the server said when to come back, and
314
                // the budget bounds how long that is listened to. A zero
315
                // wait would spin, so it goes through the backoff instead.
316
                // The grown wait is what the budget pays for. Past the
317
                // budget the backoff ladder below waits at least the plain
318
                // ask, never the grown one, so the ladder's total stays what
319
                // the retry config says and the caller's own deadline (a
320
                // worker's download window, Bazel's remote timeout) holds.
321
98
                if is_throttle && 
!min_wait.is_zero()52
{
322
48
                    let wait = self.throttle_wait(min_wait, throttles_in_a_row);
323
48
                    if throttled.saturating_add(wait) <= self.throttle_budget {
324
42
                        throttled = throttled.saturating_add(wait);
325
42
                        attempt -= 1;
326
42
                        self.report(RetryOutcome::Retried);
327
42
                        (self.sleep_fn)(wait).await;
328
42
                        continue;
329
6
                    }
330
50
                }
331
56
                let Some(
backoff33
) = iter.next() else {
332
23
                    self.report(RetryOutcome::Exhausted);
333
23
                    warn!(client = self.client, ?attempt, ?err, "Retries exhausted");
334
23
                    return Err(err.append(format!("On attempt {attempt}")));
335
                };
336
33
                self.report(RetryOutcome::Retried);
337
33
                (self.sleep_fn)(backoff.max(min_wait)).await;
338
            }
339
475
        }
340
476
    }
341
}