Skip to main content

cloudillo_action/
key_cache.rs

1//! Federation key fetch cache
2//!
3//! Provides in-memory caching for failed key fetch attempts to prevent
4//! repeated requests to unreachable or malicious federated instances.
5
6use lru::LruCache;
7use std::num::NonZeroUsize;
8use std::sync::Arc;
9
10use crate::prelude::*;
11
12/// Default cache capacity if not configured
13const DEFAULT_CACHE_CAPACITY: usize = 100;
14
15/// TTL for network errors (transient, may recover quickly)
16const TTL_NETWORK_ERROR_SECS: i64 = 5 * 60; // 5 minutes
17
18/// TTL for persistent errors (unlikely to change)
19const TTL_PERSISTENT_ERROR_SECS: i64 = 60 * 60; // 1 hour
20
21/// Type of failure that occurred during key fetch
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum FailureType {
24	/// Network error (connection refused, timeout, DNS failure)
25	NetworkError,
26	/// Key not found (404)
27	NotFound,
28	/// Permission denied (403)
29	Unauthorized,
30	/// Response parsing error (malformed JSON, invalid key format)
31	ParseError,
32}
33
34impl FailureType {
35	/// Get the TTL in seconds for this failure type
36	pub fn ttl_secs(&self) -> i64 {
37		match self {
38			FailureType::NetworkError => TTL_NETWORK_ERROR_SECS,
39			FailureType::NotFound => TTL_PERSISTENT_ERROR_SECS,
40			FailureType::Unauthorized => TTL_PERSISTENT_ERROR_SECS,
41			FailureType::ParseError => TTL_PERSISTENT_ERROR_SECS,
42		}
43	}
44
45	/// Convert from Error to FailureType
46	pub fn from_error(error: &Error) -> Self {
47		match error {
48			Error::NetworkError(_) => FailureType::NetworkError,
49			Error::NotFound => FailureType::NotFound,
50			Error::PermissionDenied | Error::Unauthorized => FailureType::Unauthorized,
51			Error::Parse => FailureType::ParseError,
52			// Default to NetworkError for unknown errors (shorter TTL)
53			_ => FailureType::NetworkError,
54		}
55	}
56}
57
58/// Entry in the failure cache
59#[derive(Debug, Clone)]
60pub struct FailureEntry {
61	/// When the failure occurred
62	pub failed_at: Timestamp,
63	/// Type of failure
64	pub failure_type: FailureType,
65	/// When we should retry (failed_at + TTL)
66	pub retry_after: Timestamp,
67}
68
69impl FailureEntry {
70	/// Create a new failure entry with TTL based on failure type
71	pub fn new(failure_type: FailureType) -> Self {
72		let now = Timestamp::now();
73		let ttl = failure_type.ttl_secs();
74		Self { failed_at: now, failure_type, retry_after: now.add_seconds(ttl) }
75	}
76
77	/// Check if this failure entry has expired
78	pub fn is_expired(&self) -> bool {
79		Timestamp::now() >= self.retry_after
80	}
81
82	/// Get seconds remaining until retry is allowed
83	pub fn seconds_until_retry(&self) -> i64 {
84		let now = Timestamp::now();
85		if now >= self.retry_after {
86			0
87		} else {
88			self.retry_after.0 - now.0
89		}
90	}
91}
92
93/// Cache for tracking failed key fetch attempts
94///
95/// Uses an LRU cache to limit memory usage while providing fast lookups.
96/// Entries automatically expire based on the failure type's TTL.
97pub struct KeyFetchCache {
98	/// Failed fetch attempts - keyed by "{issuer}:{key_id}"
99	failures: Arc<parking_lot::RwLock<LruCache<String, FailureEntry>>>,
100}
101
102impl KeyFetchCache {
103	/// Create a new cache with the specified maximum capacity
104	pub fn new(max_entries: usize) -> Self {
105		let capacity = NonZeroUsize::new(max_entries.max(1)).unwrap_or(NonZeroUsize::MIN);
106
107		Self { failures: Arc::new(parking_lot::RwLock::new(LruCache::new(capacity))) }
108	}
109
110	/// Create cache key from issuer and key_id
111	fn make_key(issuer: &str, key_id: &str) -> String {
112		format!("{}:{}", issuer, key_id)
113	}
114
115	/// Check if there's a cached (non-expired) failure for this issuer/key_id
116	///
117	/// Returns Some(FailureEntry) if we should NOT retry yet
118	/// Returns None if we should try fetching (no cache or expired)
119	pub fn check_failure(&self, issuer: &str, key_id: &str) -> Option<FailureEntry> {
120		let key = Self::make_key(issuer, key_id);
121		let mut cache = self.failures.write();
122
123		if let Some(entry) = cache.get(&key) {
124			if entry.is_expired() {
125				// Expired - remove from cache and allow retry
126				cache.pop(&key);
127				None
128			} else {
129				// Still valid - return the failure
130				Some(entry.clone())
131			}
132		} else {
133			None
134		}
135	}
136
137	/// Record a failed fetch attempt
138	pub fn record_failure(&self, issuer: &str, key_id: &str, error: &Error) {
139		let key = Self::make_key(issuer, key_id);
140		let failure_type = FailureType::from_error(error);
141		let entry = FailureEntry::new(failure_type);
142
143		debug!(
144			"Caching key fetch failure for {} (type: {:?}, retry in {} secs)",
145			key,
146			failure_type,
147			entry.seconds_until_retry()
148		);
149
150		let mut cache = self.failures.write();
151		cache.put(key, entry);
152	}
153
154	/// Clear a failure entry (e.g., after successful fetch)
155	pub fn clear_failure(&self, issuer: &str, key_id: &str) {
156		let key = Self::make_key(issuer, key_id);
157		let mut cache = self.failures.write();
158		cache.pop(&key);
159	}
160
161	/// Get the current number of entries in the cache
162	pub fn len(&self) -> usize {
163		self.failures.read().len()
164	}
165
166	/// Check if the cache is empty
167	pub fn is_empty(&self) -> bool {
168		self.failures.read().is_empty()
169	}
170
171	/// Clear all entries from the cache
172	pub fn clear(&self) {
173		self.failures.write().clear();
174	}
175}
176
177impl Default for KeyFetchCache {
178	fn default() -> Self {
179		Self::new(DEFAULT_CACHE_CAPACITY)
180	}
181}
182
183#[cfg(test)]
184mod tests {
185	use super::*;
186
187	#[test]
188	fn test_failure_type_ttl() {
189		assert_eq!(FailureType::NetworkError.ttl_secs(), 5 * 60);
190		assert_eq!(FailureType::NotFound.ttl_secs(), 60 * 60);
191		assert_eq!(FailureType::Unauthorized.ttl_secs(), 60 * 60);
192		assert_eq!(FailureType::ParseError.ttl_secs(), 60 * 60);
193	}
194
195	#[test]
196	fn test_failure_entry_expiration() {
197		let entry = FailureEntry::new(FailureType::NetworkError);
198		// Freshly created entry should not be expired
199		assert!(!entry.is_expired());
200		assert!(entry.seconds_until_retry() > 0);
201	}
202
203	#[test]
204	fn test_cache_operations() {
205		let cache = KeyFetchCache::new(10);
206
207		// Initially empty
208		assert!(cache.is_empty());
209		assert!(cache.check_failure("alice.example.com", "key-1").is_none());
210
211		// Record a failure
212		cache.record_failure("alice.example.com", "key-1", &Error::NotFound);
213
214		// Should now be cached
215		assert!(!cache.is_empty());
216		assert_eq!(cache.len(), 1);
217
218		let failure = cache.check_failure("alice.example.com", "key-1");
219		assert!(failure.is_some());
220		assert_eq!(failure.unwrap().failure_type, FailureType::NotFound);
221
222		// Clear the failure
223		cache.clear_failure("alice.example.com", "key-1");
224		assert!(cache.is_empty());
225	}
226
227	#[test]
228	fn test_cache_lru_eviction() {
229		let cache = KeyFetchCache::new(2);
230
231		cache.record_failure("a.com", "k1", &Error::NotFound);
232		cache.record_failure("b.com", "k2", &Error::NotFound);
233		assert_eq!(cache.len(), 2);
234
235		// Adding third entry should evict the least recently used
236		cache.record_failure("c.com", "k3", &Error::NotFound);
237		assert_eq!(cache.len(), 2);
238
239		// First entry should be evicted
240		assert!(cache.check_failure("a.com", "k1").is_none());
241		assert!(cache.check_failure("b.com", "k2").is_some());
242		assert!(cache.check_failure("c.com", "k3").is_some());
243	}
244
245	#[test]
246	fn test_failure_type_from_error() {
247		assert_eq!(
248			FailureType::from_error(&Error::NetworkError("test".into())),
249			FailureType::NetworkError
250		);
251		assert_eq!(FailureType::from_error(&Error::NotFound), FailureType::NotFound);
252		assert_eq!(FailureType::from_error(&Error::PermissionDenied), FailureType::Unauthorized);
253		assert_eq!(FailureType::from_error(&Error::Unauthorized), FailureType::Unauthorized);
254		assert_eq!(FailureType::from_error(&Error::Parse), FailureType::ParseError);
255	}
256}
257
258// vim: ts=4