Skip to main content

cachekit/
error.rs

1use thiserror::Error;
2
3/// Top-level error type for all CacheKit operations.
4#[derive(Debug, Error)]
5#[non_exhaustive]
6pub enum CachekitError {
7    /// An error originating from the cache backend.
8    #[error("backend error: {0}")]
9    Backend(#[from] BackendError),
10
11    /// Serialization or deserialization failed.
12    #[error("serialization error: {0}")]
13    Serialization(String),
14
15    /// Encryption or decryption failed.
16    #[error("encryption error: {0}")]
17    Encryption(String),
18
19    /// Configuration is invalid or missing required values.
20    #[error("configuration error: {0}")]
21    Config(String),
22
23    /// The payload exceeds the maximum allowed size.
24    #[error("payload too large: {size} bytes (limit: {limit} bytes)")]
25    PayloadTooLarge {
26        /// Actual payload size in bytes.
27        size: usize,
28        /// Maximum allowed size in bytes.
29        limit: usize,
30    },
31
32    /// The cache key is invalid (empty, too long, or contains illegal bytes).
33    #[error("invalid cache key: {0}")]
34    InvalidKey(String),
35}
36
37// ── BackendErrorKind ─────────────────────────────────────────────────────────
38
39/// Classifies backend errors to determine retry behaviour.
40#[derive(Debug, Clone, PartialEq, Eq)]
41#[non_exhaustive]
42pub enum BackendErrorKind {
43    /// Temporary failure — safe to retry (network blip, pool exhaustion).
44    Transient,
45    /// Permanent failure — retrying will not help (bad request, key not found).
46    Permanent,
47    /// Request did not complete within the deadline — safe to retry.
48    Timeout,
49    /// Credentials are invalid or missing — retrying will not help.
50    Authentication,
51    /// The circuit breaker is open — the call failed fast without reaching
52    /// the backend. Not retryable *now*; the breaker re-probes on its own
53    /// schedule (see `reliability::CircuitBreakerConfig::open_timeout`).
54    CircuitOpen,
55    /// The concurrency limiter shed this call before it reached the backend:
56    /// the waiting queue was full, or no permit freed up within the acquire
57    /// timeout. Not retryable *now* — an immediate retry would re-join the
58    /// same overloaded queue (see `reliability::BackpressureConfig`).
59    Backpressure,
60}
61
62impl BackendErrorKind {
63    /// Returns `true` if it is safe to retry the operation.
64    #[must_use]
65    pub fn is_retryable(&self) -> bool {
66        matches!(self, Self::Transient | Self::Timeout)
67    }
68}
69
70impl std::fmt::Display for BackendErrorKind {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        match self {
73            Self::Transient => write!(f, "transient"),
74            Self::Permanent => write!(f, "permanent"),
75            Self::Timeout => write!(f, "timeout"),
76            Self::Authentication => write!(f, "authentication"),
77            Self::CircuitOpen => write!(f, "circuit-open"),
78            Self::Backpressure => write!(f, "backpressure"),
79        }
80    }
81}
82
83// ── BackendError ─────────────────────────────────────────────────────────────
84
85/// A structured error from a cache backend.
86#[derive(Debug, Error)]
87#[error("{kind} backend error: {message}")]
88pub struct BackendError {
89    /// Classification of this error.
90    pub kind: BackendErrorKind,
91    /// Human-readable description.
92    pub message: String,
93    /// The underlying error that caused this backend error, if any.
94    #[cfg(not(any(target_arch = "wasm32", feature = "unsync")))]
95    #[source]
96    pub source: Option<Box<dyn std::error::Error + Send + Sync>>,
97    /// The underlying error that caused this backend error, if any.
98    #[cfg(any(target_arch = "wasm32", feature = "unsync"))]
99    #[source]
100    pub source: Option<Box<dyn std::error::Error>>,
101}
102
103impl BackendError {
104    /// Create a transient (retryable) backend error.
105    pub fn transient(message: impl Into<String>) -> Self {
106        Self {
107            kind: BackendErrorKind::Transient,
108            message: message.into(),
109            source: None,
110        }
111    }
112
113    /// Create a permanent (non-retryable) backend error.
114    pub fn permanent(message: impl Into<String>) -> Self {
115        Self {
116            kind: BackendErrorKind::Permanent,
117            message: message.into(),
118            source: None,
119        }
120    }
121
122    /// Create a timeout backend error.
123    pub fn timeout(message: impl Into<String>) -> Self {
124        Self {
125            kind: BackendErrorKind::Timeout,
126            message: message.into(),
127            source: None,
128        }
129    }
130
131    /// Create an authentication backend error.
132    pub fn auth(message: impl Into<String>) -> Self {
133        Self {
134            kind: BackendErrorKind::Authentication,
135            message: message.into(),
136            source: None,
137        }
138    }
139
140    /// Create a circuit-open backend error (call failed fast, backend not reached).
141    pub fn circuit_open(message: impl Into<String>) -> Self {
142        Self {
143            kind: BackendErrorKind::CircuitOpen,
144            message: message.into(),
145            source: None,
146        }
147    }
148
149    /// Create a backpressure backend error (call shed by the concurrency
150    /// limiter, backend not reached).
151    pub fn backpressure(message: impl Into<String>) -> Self {
152        Self {
153            kind: BackendErrorKind::Backpressure,
154            message: message.into(),
155            source: None,
156        }
157    }
158
159    /// Sanitize error messages to strip API keys (CWE-532).
160    pub fn sanitize_message(msg: &str, api_key: &str) -> String {
161        if api_key.is_empty() {
162            return msg.to_string();
163        }
164        msg.replace(api_key, "***")
165    }
166
167    /// Construct a [`BackendError`] from an HTTP status code and response body.
168    ///
169    /// The body is truncated to 256 Unicode scalar values to avoid inflating error messages.
170    pub fn from_http_status(status: u16, body: &[u8]) -> Self {
171        let body_str = std::str::from_utf8(body).unwrap_or("<non-utf8 body>");
172        let truncated: String = body_str.chars().take(256).collect();
173        let message = format!("HTTP {status}: {truncated}");
174
175        let kind = match status {
176            401 | 403 => BackendErrorKind::Authentication,
177            408 | 429 | 500 | 502 | 503 | 504 => BackendErrorKind::Transient,
178            _ if status >= 500 => BackendErrorKind::Transient,
179            _ => BackendErrorKind::Permanent,
180        };
181
182        Self {
183            kind,
184            message,
185            source: None,
186        }
187    }
188}