Skip to main content

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;