keyhog_core/retry.rs
1//! The one retry policy: bounded attempts, one backoff, one classification.
2//!
3//! # Why this module is small on purpose
4//!
5//! Retry is the SECOND choice, never the first. A failure that can be
6//! prevented by design must be prevented, because a retry that fires is
7//! evidence of a defect rather than a success. The trap this module is shaped
8//! to avoid is "it will just retry" becoming a reason to ship a racy read, an
9//! unbounded allocation, or a path that fails under ordinary conditions.
10//!
11//! Three deliberate properties enforce that:
12//!
13//! 1. **No catch-all cause.** [`classify_io`] returns `None` for anything it
14//! does not recognise, and `None` means permanent: the error is returned
15//! unchanged and the operation is not attempted again. Making a new failure
16//! recoverable therefore requires naming it, in public, in
17//! [`keyhog_profile::RetryCause`]. There is no bucket to quietly widen.
18//! 2. **Every attempt is counted.** [`retry_classified`] records one
19//! [`keyhog_profile::record_retry`] per retry attempt, whether or not the
20//! retry eventually succeeded. A path that silently retries a thousand
21//! times reports a thousand, so it shows up as a defect rather than as
22//! comfort.
23//! 3. **One bound, one backoff.** [`RetryPolicy::DEFAULT`] is the only policy.
24//! Callers do not get to pick a bigger number because their path is
25//! flakier; a path that needs more attempts needs a fix instead.
26//!
27//! # What must never be routed through here
28//!
29//! Cap refusals. The docker tar entry-count cap, the docker unpack budget, the
30//! PDF string-parser work budget, `--max-file-size`, and the seventeen
31//! configured source limits are deliberate refusals of input that is too big,
32//! too many, or hostile. Retrying a hostile input turns a denial-of-service
33//! defence into a denial of service. They stay one-shot refusals and are
34//! reported as coverage gaps, never as transient failures.
35//!
36//! Equally: a permission denial, a genuinely absent operator-supplied path,
37//! and a malformed URL are permanent. They fail identically on every attempt,
38//! so a retry only burns the bound and delays the report.
39
40use keyhog_profile::RetryCause;
41use std::fs::{File, Metadata};
42use std::io;
43use std::path::Path;
44use std::time::Duration;
45
46/// Bounded attempts with exponential backoff.
47///
48/// There is exactly one of these in the product. Sources, the post-scan
49/// access-target pass, and cloud adapters all use [`RetryPolicy::DEFAULT`]
50/// rather than each inventing a loop, so the worst-case added latency of a
51/// transient failure is a single reviewable number.
52#[derive(Clone, Copy, Debug, Eq, PartialEq)]
53pub struct RetryPolicy {
54 /// Total attempts, INCLUDING the first. `1` disables retry entirely.
55 pub max_attempts: u32,
56 /// Delay before the second attempt. Doubles per attempt.
57 pub initial_backoff: Duration,
58 /// Ceiling for the doubling, so a raised bound cannot become a stall.
59 pub max_backoff: Duration,
60}
61
62impl RetryPolicy {
63 /// The policy. Three attempts, 5 ms then 10 ms of backoff.
64 ///
65 /// Worst case a permanently-failing transient classification costs 15 ms
66 /// and two extra syscalls per operation. That is deliberately too small to
67 /// paper over a real defect: a walk over a tree where every file races
68 /// would spend visible wall time and report a retry count per file, which
69 /// is the signal we want rather than a silently-absorbed cost.
70 pub const DEFAULT: Self = Self {
71 max_attempts: 3,
72 initial_backoff: Duration::from_millis(5),
73 max_backoff: Duration::from_millis(40),
74 };
75
76 /// Backoff before the attempt numbered `next_attempt` (2 for the first
77 /// retry), saturating at [`Self::max_backoff`].
78 #[must_use]
79 pub fn backoff_for(&self, next_attempt: u32) -> Duration {
80 let doublings = next_attempt.saturating_sub(2).min(16);
81 let scaled = self
82 .initial_backoff
83 .saturating_mul(1u32 << doublings.min(16));
84 scaled.min(self.max_backoff)
85 }
86}
87
88impl Default for RetryPolicy {
89 fn default() -> Self {
90 Self::DEFAULT
91 }
92}
93
94/// Who named the path, which decides whether "not found" is a race or a fact.
95///
96/// This distinction is the whole reason `ENOENT` is not classified by error
97/// kind alone. An operator who passes `--path /nope` gets a permanent error on
98/// the first attempt, because retrying their typo three times helps nobody. A
99/// file the walker already enumerated and then could not open genuinely raced
100/// with another process, so it is worth one bounded retry before it becomes a
101/// coverage gap.
102#[derive(Clone, Copy, Debug, Eq, PartialEq)]
103pub enum PathOrigin {
104 /// The operator named this path directly. Absence is a user error.
105 OperatorSupplied,
106 /// A walker already observed this entry. Absence is a race.
107 Enumerated,
108}
109
110/// Classify an IO error, or return `None` when it is permanent.
111///
112/// `None` is the default for anything unrecognised. That is the safe
113/// direction: an unclassified failure surfaces immediately as a real error
114/// instead of being retried on a guess.
115#[must_use]
116pub fn classify_io(error: &io::Error, origin: PathOrigin) -> Option<RetryCause> {
117 match error.kind() {
118 io::ErrorKind::Interrupted => return Some(RetryCause::Interrupted),
119 io::ErrorKind::WouldBlock => return Some(RetryCause::WouldBlock),
120 io::ErrorKind::TimedOut
121 | io::ErrorKind::ConnectionReset
122 | io::ErrorKind::ConnectionAborted => return Some(RetryCause::Network),
123 // A path the walker already saw and that has since gone is a race with
124 // another writer. A path the operator named is simply not there.
125 io::ErrorKind::NotFound if matches!(origin, PathOrigin::Enumerated) => {
126 return Some(RetryCause::VanishedUnderWalk)
127 }
128 // PermissionDenied is PERMANENT by intent. A chmod-000 file fails
129 // identically on every attempt, so retrying it burns the bound and
130 // changes nothing; it belongs in the report as a coverage gap.
131 _ => {}
132 }
133 classify_raw_os(error.raw_os_error()?, origin)
134}
135
136/// Errno cases `io::ErrorKind` does not name on stable Rust.
137fn classify_raw_os(errno: i32, origin: PathOrigin) -> Option<RetryCause> {
138 #[cfg(unix)]
139 {
140 // ESTALE: an NFS handle went stale under us, which is the networked
141 // form of "vanished under walk" and recovers on a fresh lookup.
142 const ESTALE: i32 = 116;
143 const EBUSY: i32 = 16;
144 const ETXTBSY: i32 = 26;
145 match errno {
146 ESTALE if matches!(origin, PathOrigin::Enumerated) => {
147 return Some(RetryCause::VanishedUnderWalk)
148 }
149 EBUSY | ETXTBSY => return Some(RetryCause::Locked),
150 _ => {}
151 }
152 }
153 #[cfg(windows)]
154 {
155 // ERROR_SHARING_VIOLATION / ERROR_LOCK_VIOLATION: another process holds
156 // the file open without sharing. Ordinary on Windows and short-lived.
157 const ERROR_SHARING_VIOLATION: i32 = 32;
158 const ERROR_LOCK_VIOLATION: i32 = 33;
159 if matches!(errno, ERROR_SHARING_VIOLATION | ERROR_LOCK_VIOLATION) {
160 return Some(RetryCause::Locked);
161 }
162 }
163 let _ = (errno, origin); // LAW10: cfg-only unused-parameter binding on platforms without raw retry codes; no Result is discarded.
164 None
165}
166
167/// Run `op` under the shared policy, retrying only what `classify` names.
168///
169/// Every retry attempt is counted through the profiler before it is made. The
170/// error from the FINAL attempt is returned unchanged, so a caller's own
171/// diagnostics keep the real errno rather than a wrapper.
172pub fn retry_classified<T, E, C, F>(policy: RetryPolicy, classify: C, mut op: F) -> Result<T, E>
173where
174 C: Fn(&E) -> Option<RetryCause>,
175 F: FnMut() -> Result<T, E>,
176{
177 let mut attempt = 1u32;
178 loop {
179 match op() {
180 Ok(value) => return Ok(value),
181 Err(error) => {
182 if attempt >= policy.max_attempts {
183 return Err(error);
184 }
185 // No catch-all: an unclassified error is permanent.
186 let Some(cause) = classify(&error) else {
187 return Err(error);
188 };
189 attempt += 1;
190 keyhog_profile::record_retry(cause);
191 let backoff = policy.backoff_for(attempt);
192 if !backoff.is_zero() {
193 std::thread::sleep(backoff);
194 }
195 }
196 }
197 }
198}
199
200/// [`retry_classified`] specialised to [`io::Error`] and [`classify_io`].
201pub fn retry_io<T, F>(policy: RetryPolicy, origin: PathOrigin, op: F) -> io::Result<T>
202where
203 F: FnMut() -> io::Result<T>,
204{
205 retry_classified(policy, |error| classify_io(error, origin), op)
206}
207
208/// A file and the metadata of the exact inode that was opened.
209pub struct OpenedFile {
210 /// The open handle. Every subsequent read must go through THIS, never
211 /// through a second lookup of the same path.
212 pub file: File,
213 /// Metadata taken from the open descriptor, so it describes the inode the
214 /// handle refers to rather than whatever the name resolves to now.
215 pub metadata: Metadata,
216}
217
218/// Open an already-enumerated path once and take its metadata from the HANDLE.
219///
220/// This exists to DESIGN OUT the stat-then-open race rather than retry it.
221/// Code that calls `fs::metadata(path)` to decide a size or a file kind and
222/// then calls `File::open(path)` performs two independent path lookups, and
223/// another process can replace the inode in between. The second lookup can
224/// fail (a spurious error for a file that is perfectly readable), or worse,
225/// succeed against a DIFFERENT file, so the size that was checked against a
226/// cap is not the size that gets read.
227///
228/// `File::open` followed by `File::metadata` is one lookup plus an `fstat` on
229/// the resulting descriptor. There is no window: the metadata always describes
230/// the inode the handle holds open, and on Unix that inode stays readable
231/// through the handle even if the name is unlinked afterwards.
232///
233/// The bounded retry here covers only the remaining genuine race, the entry
234/// vanishing between enumeration and this single open.
235pub fn open_enumerated(path: &Path) -> io::Result<OpenedFile> {
236 retry_io(RetryPolicy::DEFAULT, PathOrigin::Enumerated, || {
237 let file = File::open(path)?;
238 let metadata = file.metadata()?;
239 Ok(OpenedFile { file, metadata })
240 })
241}
242
243/// Wraps a [`FileContentSource`](crate::FileContentSource) so its transient
244/// arm is retried under the shared policy and its permanent arm is not.
245///
246/// This is the ONE place a content read is retried. The wrapped source has
247/// already designed out what it can: it opens once and works from the handle,
248/// so there is no check-then-use race of its own making. What is left is a
249/// genuinely external race (the file removed, replaced, or locked between the
250/// scan and this pass), which is the narrow case retry is for.
251///
252/// A permanent failure is returned on the first attempt, unchanged.
253pub struct RetryingContentSource<'a> {
254 inner: &'a dyn crate::FileContentSource,
255 policy: RetryPolicy,
256}
257
258impl<'a> RetryingContentSource<'a> {
259 /// Wrap `inner` with [`RetryPolicy::DEFAULT`].
260 #[must_use]
261 pub fn new(inner: &'a dyn crate::FileContentSource) -> Self {
262 Self {
263 inner,
264 policy: RetryPolicy::DEFAULT,
265 }
266 }
267}
268
269impl crate::FileContentSource for RetryingContentSource<'_> {
270 fn read_prefix(
271 &self,
272 path: &str,
273 max_bytes: u64,
274 ) -> Result<crate::FileContent, crate::ContentError> {
275 retry_classified(
276 self.policy,
277 |error| match error {
278 // The source already told us which arm this is; that
279 // classification is its own, made where the errno was seen.
280 crate::ContentError::TransientRead => Some(RetryCause::VanishedUnderWalk),
281 crate::ContentError::PermanentRead | crate::ContentError::NotUtf8 => None,
282 },
283 || self.inner.read_prefix(path, max_bytes),
284 )
285 }
286}