Skip to main content

decdn_cache/
retry.rs

1//! Origin pull-through retry policy with exponential backoff and jitter (#285).
2//!
3//! Wraps a single pull-through attempt in a bounded retry loop. The adapter
4//! (or the engine's per-attempt closure) classifies each failure as
5//! [`OriginPullError::Transient`] or [`OriginPullError::Permanent`]; this
6//! module only decides *how many* transient retries to run and *how long*
7//! to sleep between them.
8//!
9//! ## Body-phase retry (#519)
10//!
11//! The loop also covers body-phase failures via two paths, both gated on
12//! the same `max_retries` budget:
13//!
14//! - **Buffer-then-commit (small blobs):** when the adapter advertises a
15//!   `size_hint` at or below [`RetryPolicy::buffered_max_bytes`] (default
16//!   4 MiB), the engine's per-attempt closure drains the stream into a
17//!   bounded `BytesMut` before handing it to iroh-blobs. Drain errors are
18//!   classified via the internal `classify_io_error` helper (which
19//!   recognises typed `BlobTooLargeMarker` and `OriginError::*` as
20//!   Permanent, treats `io::ErrorKind::{ConnectionReset, TimedOut,
21//!   UnexpectedEof, …}` and the catch-all `Other` as Transient) and
22//!   re-fed to the loop, so small blobs get mid-stream retries at a
23//!   bounded memory cost.
24//! - **Abort + restart (large blobs):** the engine's per-attempt closure
25//!   commits to `iroh-blobs::add_stream` directly. On mid-stream
26//!   `io::Error`, the partial `TempTag` is dropped (iroh-blobs GC
27//!   reclaims the bytes at `cache.gc_interval_sec` cadence) and the
28//!   error is classified, restarting the loop from the headers phase.
29//!   Memory stays bounded; disk amplification is
30//!   `(1 + max_retries) * max_blob_bytes` worst case until GC.
31//!
32//! ## Coalescing interaction and the retry-amplification bound (#1615)
33//!
34//! [`crate::CacheEngine::get`] coalesces concurrent misses for the same hash
35//! through a single owner task (#305). The retry loop runs *inside* that owner;
36//! waiters block on the shared `Notify` until the owner either succeeds or
37//! exhausts. After an owner exhausts, one waiter re-elects itself as the next
38//! owner and runs its own full retry budget — so, taken alone, a burst of `N`
39//! concurrent waiters on a down origin could drive up to `N` *sequential* full
40//! retry budgets.
41//!
42//! That amplification is bounded, but by the per-origin circuit-breaker
43//! ([`crate::circuit_breaker`], #963) rather than by anything in this module.
44//! Each budget-exhaustion against a down origin records one `Unavailable`
45//! outcome; after `failure_threshold` consecutive exhaustions the breaker
46//! trips OPEN and every later waiter short-circuits *before* the retry loop
47//! runs. The worst case is therefore `failure_threshold` full budgets per
48//! origin per cooldown window — a constant, independent of `N`. The
49//! integration test `coalesced_owner_exhaustion_bounded_by_circuit_breaker`
50//! pins this, and also pins the O(`N`) fallback that returns when an operator
51//! disables the breaker.
52//!
53//! A tighter per-hash bound — the owner's failure *shared* to all waiters so
54//! not even `failure_threshold` budgets run — is deliberately NOT implemented.
55//! Coalesced waiters do not all have the same origin reach: a `populate_local`
56//! owner (local origins only, #1116) and a `get` / `populate` waiter (full
57//! chain including the paid `Peer` origin) can coalesce on one hash, and
58//! re-election is load-bearing there — a local-only owner's `NotFound` must
59//! not short-circuit a full-chain waiter that could still succeed via `Peer`.
60//! Sharing the owner's failure blindly would turn that into a wrong answer;
61//! correct sharing would have to be origin-reach-aware. The circuit-breaker
62//! already sheds the outage load without that complexity, so the shared budget
63//! is not worth its risk on this hot, cancellation-sensitive path.
64
65use std::future::Future;
66use std::io;
67use std::sync::Arc;
68use std::time::Duration;
69
70use bytes::{Bytes, BytesMut};
71use decdn_config_types::RetryPolicy;
72use futures_util::StreamExt;
73use iroh_blobs::Hash;
74
75use crate::error::{OriginError, OriginPullError};
76use crate::metrics::CacheMetrics;
77use crate::origin::{BlobTooLargeMarker, OriginByteStream};
78
79/// Compute the backoff delay before the `attempt`-th retry (0-indexed:
80/// `attempt = 0` is the first retry, immediately after the initial
81/// failure). The schedule is `min(initial * 2^attempt, max)` with
82/// equal-jitter applied.
83///
84/// Lives here (not on [`RetryPolicy`], which moved to the
85/// `decdn-config-types` leaf crate) because it needs `rand` for the
86/// jitter sample — keeping `rand` out of the leaf is the point of #578.
87///
88/// Saturating math throughout: `checked_shl` handles huge `attempt`
89/// values without panicking on `1u64 << 64`, and the eventual
90/// `min(_, max_backoff_ms)` collapses everything to the cap.
91fn delay_for(policy: RetryPolicy, attempt: u32) -> Duration {
92    let multiplier = 1u64.checked_shl(attempt).unwrap_or(u64::MAX);
93    let exp = policy.initial_backoff_ms.saturating_mul(multiplier);
94    let base = exp.min(policy.max_backoff_ms);
95
96    // Equal jitter: factor lies in [1 - r/2, 1 + r/2).
97    let r = policy.jitter_ratio.clamp(0.0, 1.0);
98    let factor = 1.0_f64 - (r / 2.0) + rand::random::<f64>() * r;
99    #[allow(clippy::cast_precision_loss)]
100    let base_f = base as f64;
101    #[allow(clippy::cast_precision_loss)]
102    let upper = u64::MAX as f64;
103    let scaled = (base_f * factor).clamp(0.0, upper);
104    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
105    let ms = scaled as u64;
106    Duration::from_millis(ms)
107}
108
109/// Whether a terminal pull-through failure left the origin looking
110/// *unreachable* (a transient that exhausted the retry budget) or merely
111/// unable to serve *this* object (an immediate permanent — 4xx, decode,
112/// cap). Used by the per-origin circuit-breaker (#963) to decide whether
113/// an attempt counts toward the trip threshold; the plain `Result` the
114/// engine folds this into collapses both into `OriginPullError::Permanent`,
115/// erasing the distinction the breaker needs.
116#[derive(Debug, Clone, Copy, PartialEq, Eq)]
117pub(crate) enum TerminalFailure {
118    /// The terminal error was a transient that exhausted `max_retries`
119    /// (or a `max_retries = 0` single transient). The origin looks
120    /// unavailable.
121    TransientExhausted,
122    /// The terminal error was an immediate `Permanent` — the origin
123    /// responded but can't serve this object. Not an outage.
124    Permanent,
125}
126
127/// The retry loop, with a terminal-failure classification for the
128/// circuit-breaker (#963). On `Ok`, the second tuple element is `None`.
129/// On `Err`, it carries whether the failure was a transient-exhaustion
130/// (origin-unavailable) or an immediate permanent (per-object).
131pub(crate) async fn run_with_retry_classified<F, Fut, T>(
132    policy: RetryPolicy,
133    metrics: Option<&Arc<CacheMetrics>>,
134    hash: Hash,
135    mut attempt_fn: F,
136) -> (Result<T, OriginPullError>, Option<TerminalFailure>)
137where
138    F: FnMut() -> Fut,
139    Fut: Future<Output = Result<T, OriginPullError>>,
140{
141    let mut attempt: u32 = 0;
142    loop {
143        match attempt_fn().await {
144            Ok(value) => return (Ok(value), None),
145            Err(OriginPullError::Permanent(e)) => {
146                return (
147                    Err(OriginPullError::Permanent(e)),
148                    Some(TerminalFailure::Permanent),
149                );
150            }
151            Err(OriginPullError::Transient(e)) => {
152                if attempt >= policy.max_retries {
153                    // Only count exhaustion when at least one retry actually
154                    // fired. With `max_retries = 0` (operator opted out)
155                    // a single transient failure is just a failure, not a
156                    // burned-out retry budget — bumping the counter would
157                    // ruin alerts that page on actual exhaustion.
158                    if attempt > 0
159                        && let Some(m) = metrics
160                    {
161                        m.origin_retry_exhausted.inc();
162                    }
163                    tracing::error!(
164                        %hash,
165                        attempts = attempt.saturating_add(1),
166                        max_retries = policy.max_retries,
167                        error = %format_args!("{e:#}"),
168                        "origin fetch exhausted retry budget; surfacing as permanent",
169                    );
170                    return (
171                        Err(OriginPullError::Permanent(e)),
172                        Some(TerminalFailure::TransientExhausted),
173                    );
174                }
175                let sleep = delay_for(policy, attempt);
176                let sleep_ms = u64::try_from(sleep.as_millis()).unwrap_or(u64::MAX);
177                tracing::warn!(
178                    %hash,
179                    attempt = attempt + 1,
180                    of = policy.max_retries,
181                    sleep_ms,
182                    error = %format_args!("{e:#}"),
183                    "origin fetch transient failure; retrying after backoff",
184                );
185                tokio::time::sleep(sleep).await;
186                attempt = attempt.saturating_add(1);
187            }
188        }
189    }
190}
191
192/// True when the origin's advertised `size_hint` is small enough to
193/// buffer in memory for body-phase retry classification. Unknown
194/// (`None`) hint falls through to streaming — buffering an unknown-size
195/// body would let a lying origin overrun the operator's memory budget.
196pub(crate) const fn should_buffer(size_hint: Option<u64>, buffered_max_bytes: u64) -> bool {
197    if buffered_max_bytes == 0 {
198        return false;
199    }
200    match size_hint {
201        Some(n) => n <= buffered_max_bytes,
202        None => false,
203    }
204}
205
206/// Drain `stream` into a contiguous `Bytes`, capping the buffer at
207/// `cap` bytes. Mirrors [`crate::origin::OriginFetch::collect_to_bytes`] but adds:
208///
209/// - A running cap: chunks past `cap` produce an `io::Error` whose
210///   inner is a [`BlobTooLargeMarker`], so the caller can downcast and
211///   surface `CacheError::BlobTooLarge` instead of a generic origin
212///   error.
213/// - Per-chunk `pull_through_bytes` metric bumps that mirror the streaming
214///   path's egress accounting (every byte the origin sent is
215///   billed, even on a drain that ultimately fails).
216pub(crate) async fn drain_to_bytes(
217    mut stream: OriginByteStream,
218    cap: u64,
219    metrics: Option<&Arc<CacheMetrics>>,
220) -> io::Result<Bytes> {
221    // Bound the upfront allocation regardless of `cap` so a hostile
222    // origin advertising `Content-Length: 10 TiB` (truncated to `cap`
223    // by the caller, but still potentially large) can't pre-commit a
224    // huge virtual region before the first byte arrives. Matches the
225    // 1 MiB initial-capacity hint used in `OriginFetch::collect_to_bytes`.
226    const INITIAL_CAP: usize = 1 << 20; // 1 MiB
227    let initial = usize::try_from(cap).unwrap_or(INITIAL_CAP).min(INITIAL_CAP);
228    let mut buf = BytesMut::with_capacity(initial);
229    let mut total: u64 = 0;
230    while let Some(chunk) = stream.next().await {
231        let chunk = chunk?;
232        if let Some(m) = metrics {
233            m.pull_through_bytes
234                .inc_by(u64::try_from(chunk.len()).unwrap_or(u64::MAX));
235        }
236        total = total.saturating_add(chunk.len() as u64);
237        if total > cap {
238            return Err(io::Error::other(BlobTooLargeMarker { max_bytes: cap }));
239        }
240        buf.extend_from_slice(&chunk);
241    }
242    Ok(buf.freeze())
243}
244
245/// Classify a body-phase `io::Error` into the retry loop's
246/// [`OriginPullError`] taxonomy. Used by both the drain path
247/// ([`drain_to_bytes`]) and the streaming path (engine's
248/// side-channel reader) so the classification is consistent
249/// across the two body-phase entry points.
250///
251/// The decision tree:
252///
253/// 1. If the `io::Error` wraps a typed inner via
254///    `io::Error::other(typed)`, recover the typed variant first:
255///    [`BlobTooLargeMarker`] → `Permanent(BlobTooLarge)`,
256///    [`OriginError`] (e.g. `DecompressionFailed`) → `Permanent`.
257///    These are deterministic protocol or cap violations — retry
258///    won't help and would just waste budget.
259///
260/// 2. Otherwise dispatch on `io::ErrorKind`. The Transient set:
261///    `Interrupted`, `TimedOut`, `WouldBlock`, `ResourceBusy`,
262///    `BrokenPipe`, `ConnectionReset`, `ConnectionAborted`,
263///    `ConnectionRefused`, `NotConnected`, `UnexpectedEof`, and the
264///    catch-all `Other`. `UnexpectedEof` is the load-bearing kind
265///    for mid-body resets behind a flaky LB — reqwest surfaces
266///    truncated Content-Length responses through it. `Other`
267///    defaults to Transient because the HTTP and S3 origin adapters
268///    wrap reqwest / SDK body errors via `io::Error::other(...)` —
269///    mostly network/body failures (resets, timeouts, truncation), so
270///    Transient is the safer default for them.
271///    Anything not in the Transient set is classified Permanent
272///    (fail-fast over retry-storm for unrecognised modes).
273pub(crate) fn classify_io_error(e: io::Error) -> OriginPullError {
274    // Peek the inner *without* consuming so we can preserve the
275    // original `e` (and its `raw_os_error` / Display) when no typed
276    // marker matches. `io::Error::from_raw_os_error` / `last_os_error`
277    // produce errors whose `into_inner()` returns `None` — consuming
278    // up front would silently strip the OS error code.
279    let has_typed_marker = e
280        .get_ref()
281        .is_some_and(|r| r.is::<BlobTooLargeMarker>() || r.is::<OriginError>());
282    if !has_typed_marker {
283        let kind = e.kind();
284        return classify_by_kind(kind, e);
285    }
286    // We confirmed a typed marker via `is::<>`; consume and chain
287    // `Box::downcast` (returns the original box back through `Err`
288    // on a mismatch). Both downcasts can only fail if a third typed
289    // variant slipped in between the `get_ref` peek and the consume
290    // — defensive fall-back rewraps and routes through `classify_by_kind`.
291    let kind = e.kind();
292    let Some(inner) = e.into_inner() else {
293        // Structurally unreachable: `get_ref` returned `Some` so the
294        // Repr is `Custom`, and `Custom`'s `into_inner` always returns
295        // `Some`. If std's invariant ever changes, fall through.
296        return classify_by_kind(kind, io::Error::from(kind));
297    };
298    let inner = match inner.downcast::<BlobTooLargeMarker>() {
299        Ok(marker) => {
300            return OriginPullError::Permanent(anyhow::anyhow!(
301                "origin body exceeded max_blob_bytes={}",
302                marker.max_bytes
303            ));
304        }
305        Err(b) => b,
306    };
307    match inner.downcast::<OriginError>() {
308        Ok(typed) => OriginPullError::Permanent(anyhow::Error::from(*typed)),
309        Err(b) => classify_by_kind(kind, io::Error::new(kind, b)),
310    }
311}
312
313/// Classify a body-phase failure by `io::ErrorKind` alone. Takes the
314/// original `io::Error` so `raw_os_error()` and any Display payload
315/// flow through unchanged into the surfaced `anyhow::Error`. Used
316/// when the typed-marker downcast didn't fire.
317fn classify_by_kind(kind: io::ErrorKind, e: io::Error) -> OriginPullError {
318    let err = anyhow::Error::new(e);
319    match kind {
320        io::ErrorKind::Interrupted
321        | io::ErrorKind::TimedOut
322        | io::ErrorKind::WouldBlock
323        | io::ErrorKind::ResourceBusy
324        | io::ErrorKind::BrokenPipe
325        | io::ErrorKind::ConnectionReset
326        | io::ErrorKind::ConnectionAborted
327        | io::ErrorKind::ConnectionRefused
328        | io::ErrorKind::NotConnected
329        | io::ErrorKind::UnexpectedEof
330        | io::ErrorKind::Other => OriginPullError::Transient(err),
331        _ => OriginPullError::Permanent(err),
332    }
333}
334
335#[cfg(test)]
336#[allow(clippy::unwrap_used, clippy::expect_used)] // tests
337mod tests;