decdn_cache/error.rs
1//! Error type for the cache engine.
2
3use std::fmt;
4
5use iroh_blobs::Hash;
6use thiserror::Error;
7
8/// Errors surfaced by [`crate::CacheEngine`].
9///
10/// A variant that wraps a cause names only itself in `Display` and returns the
11/// cause from [`std::error::Error::source`]. A `.chain()` walk therefore reaches
12/// every cause under a `CacheError`, such as a local disk fault that
13/// `decdn_client::classify` looks for, and a chain formatter (`{:#}` or `{:?}` on an
14/// `anyhow::Error`) names each cause once. Log a bare `CacheError` with
15/// [`CacheError::display_chain`], not with plain `Display`.
16#[derive(Debug, Error)]
17pub enum CacheError {
18 /// The requested blob was not found locally and the configured origin
19 /// returned a not-found response.
20 #[error("blob {hash} not found in cache or at origin")]
21 NotFound {
22 /// The hash that was requested.
23 hash: Hash,
24 },
25
26 /// The origin returned bytes whose BLAKE3 hash did not match what was
27 /// requested. The bytes are rejected and are *not* inserted into the store.
28 /// A whole-stream failure: the assembled bytes hashed to `actual`, not the
29 /// requested `expected`. A mid-stream bao chunk-group verify failure surfaces
30 /// as [`Self::VerifyFailed`] instead, so `actual` here is always a real hash.
31 #[error("origin returned blob {actual} when {expected} was requested")]
32 HashMismatch {
33 /// The hash the caller asked for.
34 expected: Hash,
35 /// The hash the origin's bytes actually produced.
36 actual: Hash,
37 },
38
39 /// A teed bao verified-stream (#856, ADR 038) failed to decode against the
40 /// content root: an interior chunk group did not verify — a corrupt or lying
41 /// upstream. Distinct from [`Self::HashMismatch`], which is a whole-stream
42 /// hash mismatch; here the failure is at an interior group, so there is no
43 /// meaningful whole-blob "actual" hash to report (#915). The bytes are
44 /// rejected and are *not* inserted into the store.
45 #[error("origin bytes failed bao verification against {expected}")]
46 VerifyFailed {
47 /// The hash the caller asked for (the content root the stream failed
48 /// to verify against).
49 expected: Hash,
50 },
51
52 /// The origin returned a blob larger than the configured
53 /// `max_blob_size_mb`.
54 #[error("blob {hash} exceeds max_blob_size of {limit_bytes} bytes")]
55 BlobTooLarge {
56 /// The hash that was requested.
57 hash: Hash,
58 /// The configured ceiling, in bytes.
59 limit_bytes: u64,
60 },
61
62 /// The cache engine was asked to pull a miss but no origin was configured.
63 #[error("cache miss for {hash} but no origin is configured")]
64 NoOrigin {
65 /// The hash that was requested.
66 hash: Hash,
67 },
68
69 /// The origin backend failed (network error, non-200 status, etc).
70 #[error("origin error fetching {hash}")]
71 OriginError {
72 /// The hash that was requested.
73 hash: Hash,
74 /// Underlying error from the origin backend.
75 #[source]
76 source: anyhow::Error,
77 },
78
79 /// This node's own iroh-blobs store failed, or a store operation asked for
80 /// bytes the store does not hold. Always a local fault: the bytes a sender
81 /// feeds into an admit failing to arrive is [`Self::Feed`], and bytes that
82 /// fail to verify are [`Self::VerifyFailed`].
83 #[error("store error")]
84 Store(#[source] anyhow::Error),
85
86 /// The bao stream fed into an admit ended early or failed to read, or its
87 /// claimed size cannot belong to the hash. A fault of the sender's delivery,
88 /// not of this node's store.
89 #[error("admit feed failed")]
90 Feed(#[source] anyhow::Error),
91
92 /// A code bug in the cache: a broken internal invariant or a caught panic.
93 /// Never an origin fault and never a store fault, so an operator reads it
94 /// as a bug to report, not as a backend to check.
95 #[error("internal fault")]
96 Internal(#[source] anyhow::Error),
97
98 /// The local evicted-hash set is full. Hard cap on the number of
99 /// distinct hashes the operator may evict in a single cache lifetime
100 /// (issue #279) — protects the in-memory `HashSet` and the on-disk
101 /// `evicted.log` from unbounded growth under e.g. an automation
102 /// gone-wrong that mass-evicts on every request. Hitting this is
103 /// well outside normal usage; the operator should investigate the
104 /// caller before raising the cap.
105 #[error("evicted-hash set full ({limit} entries); refusing to add more")]
106 EvictionLimitExceeded {
107 /// The cap that was reached.
108 limit: usize,
109 },
110}
111
112impl CacheError {
113 /// Render this error and every cause under it on one line
114 /// (`store error: context: root cause`). Use it wherever a bare
115 /// `CacheError` reaches a log line or an operator-facing message.
116 pub fn display_chain(&self) -> ErrorChain<'_> {
117 ErrorChain::new(self)
118 }
119
120 /// Walk the source chain of [`Self::OriginError`] looking for an
121 /// [`OriginError`]. Returns `Some` when the failure originated in
122 /// the HTTP origin's encoding/decompression layer, even after
123 /// [`anyhow::Context::with_context`] wrappers were stacked on top —
124 /// `anyhow::Error::chain()` exposes every cause in the chain, and
125 /// the typed variant we want lives at the root.
126 ///
127 /// Tests assert on the typed variant via `matches!`; observability
128 /// code can switch on the variant rather than parsing `Display`
129 /// strings. Returns `None` for non-origin failures or origin failures
130 /// that didn't come from `HttpOrigin`'s decoder.
131 pub fn origin_error_kind(&self) -> Option<&OriginError> {
132 let Self::OriginError { source, .. } = self else {
133 return None;
134 };
135 source
136 .chain()
137 .find_map(|cause| cause.downcast_ref::<OriginError>())
138 }
139}
140
141/// `Content-Encoding` values [`crate::origin::HttpOrigin`] knows how to
142/// decode. Used as the `encoding` field of
143/// [`OriginError::DecompressionFailed`] so that variant is structurally
144/// reachable only from genuine decoder failures — making
145/// `DecompressionFailed { encoding: "lol", … }` unrepresentable by
146/// construction.
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum SupportedEncoding {
149 /// `Content-Encoding: gzip` (and the legacy `x-gzip` alias).
150 Gzip,
151 /// `Content-Encoding: zstd`.
152 Zstd,
153}
154
155impl fmt::Display for SupportedEncoding {
156 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157 match self {
158 Self::Gzip => f.write_str("gzip"),
159 Self::Zstd => f.write_str("zstd"),
160 }
161 }
162}
163
164/// Errors raised by the [`crate::origin::HttpOrigin`] decompression layer.
165///
166/// Surfaced to the engine through the `anyhow::Error` source on
167/// [`CacheError::OriginError`] (the `Origin` trait returns `anyhow::Result`).
168/// Use [`CacheError::origin_error_kind`] to recover the typed variant —
169/// the chain walk works through any number of `with_context` wrappers.
170#[derive(Debug, Error)]
171pub enum OriginError {
172 /// The response advertised a `Content-Encoding` value the origin layer
173 /// does not understand. We refuse to silently pass the bytes through —
174 /// the BLAKE3 verify in the engine would fail, but with a confusing
175 /// "hash mismatch" message rather than a precise "your origin is using
176 /// an encoding I don't support" message.
177 ///
178 /// `encoding` is `Box<str>` rather than `String` to keep the variant
179 /// 16 bytes on a 64-bit target — error enums get cloned/passed
180 /// around the chain a lot, and the underlying buffer is never
181 /// resized after construction.
182 #[error("unsupported Content-Encoding: {encoding}")]
183 UnsupportedEncoding {
184 /// The raw header value the origin sent.
185 encoding: Box<str>,
186 },
187
188 /// The response carried a non-ASCII `Content-Encoding` byte sequence
189 /// that we refuse to interpret. RFC 9110 § 5.6.7 restricts coding
190 /// names to ASCII tokens; treating an arbitrary byte sequence as
191 /// "no encoding" would silently pass through what may be a real
192 /// (mis-cased / mis-typed) compression directive.
193 #[error("malformed Content-Encoding header (non-ASCII or unprintable)")]
194 MalformedEncoding,
195
196 /// Decompression of the response body failed mid-stream. Either the
197 /// origin sent a corrupt payload or an encoding mismatch slipped past
198 /// the header check (e.g. `Content-Encoding: gzip` but the bytes are
199 /// actually zstd). The `encoding` field is typed (closed set) so
200 /// nonsense values are unrepresentable — this variant is reachable
201 /// only from the gzip and zstd code paths.
202 #[error("failed to decompress {encoding} response body")]
203 DecompressionFailed {
204 /// The supported encoding whose decoder rejected the body.
205 encoding: SupportedEncoding,
206 /// Underlying decoder error.
207 #[source]
208 source: std::io::Error,
209 },
210}
211
212/// Renders any error and each [`std::error::Error::source`] under it on one
213/// line, joined by `": "`. Built by [`CacheError::display_chain`], or by
214/// [`ErrorChain::new`] for any other error.
215///
216/// Each cause appears once only when every link leaves its `source()` out of
217/// its own `Display`, as [`CacheError`] does. A link that repeats its source in
218/// `Display` repeats that text here.
219#[derive(Debug, Clone, Copy)]
220pub struct ErrorChain<'a>(&'a (dyn std::error::Error + 'static));
221
222impl<'a> ErrorChain<'a> {
223 /// Wrap `err` for one-line chain rendering.
224 pub const fn new(err: &'a (dyn std::error::Error + 'static)) -> Self {
225 Self(err)
226 }
227}
228
229impl fmt::Display for ErrorChain<'_> {
230 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231 write!(f, "{}", self.0)?;
232 let mut cause = self.0.source();
233 while let Some(err) = cause {
234 write!(f, ": {err}")?;
235 cause = err.source();
236 }
237 Ok(())
238 }
239}
240
241/// Convenience result alias.
242pub type CacheResult<T> = std::result::Result<T, CacheError>;
243
244/// Outcome of a single [`crate::origin::Origin::fetch`] call, classified by
245/// whether a retry is appropriate. The cache engine's retry loop drives off
246/// this distinction — `Transient` failures are retried up to
247/// [`crate::RetryPolicy::max_retries`], `Permanent` failures surface to the
248/// caller immediately.
249///
250/// Both variants carry an [`anyhow::Error`] so adapters keep their
251/// `with_context` chains intact. The retry loop, the circuit breaker and the
252/// presence probe read the classification. A caller that surfaces the failure
253/// drops it with [`Self::into_inner`] and returns the cause as
254/// [`CacheError::OriginError`].
255#[derive(Debug, Error)]
256pub enum OriginPullError {
257 /// A retry-eligible failure: HTTP 5xx/408/429, connect/headers/chunk
258 /// timeouts, transport-level connection resets, or transient I/O on the
259 /// filesystem origin (`Interrupted`, `TimedOut`, `ResourceBusy`, `WouldBlock`).
260 #[error(transparent)]
261 Transient(anyhow::Error),
262
263 /// A failure that won't be cured by retrying: HTTP 4xx (other than 404,
264 /// which maps to [`crate::origin::OriginFetch::NotFound`]),
265 /// encoding/decompression problems, size-cap breaches, symlink-escape
266 /// or permission-denied on the filesystem origin.
267 #[error(transparent)]
268 Permanent(anyhow::Error),
269}
270
271impl OriginPullError {
272 /// True when the variant is `Transient` — the retry loop tests this in
273 /// the hot path; keep it `const` so it inlines cleanly.
274 pub const fn is_transient(&self) -> bool {
275 matches!(self, Self::Transient(_))
276 }
277
278 /// Unwrap the underlying `anyhow::Error`, discarding the
279 /// transient/permanent classification. Used by the cache engine when
280 /// collapsing into [`CacheError::OriginError`] after retries are
281 /// exhausted (or on the first permanent failure).
282 pub fn into_inner(self) -> anyhow::Error {
283 match self {
284 Self::Transient(e) | Self::Permanent(e) => e,
285 }
286 }
287
288 /// Apply `f` to the inner `anyhow::Error` while preserving the
289 /// transient/permanent classification. Lets adapters stack
290 /// `with_context` (or any other error transform) onto a typed error
291 /// without flipping it to the wrong variant.
292 pub(crate) fn map_inner<F>(self, f: F) -> Self
293 where
294 F: FnOnce(anyhow::Error) -> anyhow::Error,
295 {
296 match self {
297 Self::Transient(e) => Self::Transient(f(e)),
298 Self::Permanent(e) => Self::Permanent(f(e)),
299 }
300 }
301}
302
303#[cfg(test)]
304mod tests;