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;