Skip to main content

jwks_cache/cache/
entry.rs

1//! Cache entry definitions and state management helpers.
2
3// self
4use crate::{
5	_prelude::*,
6	cache::state::{CachePayload, CacheState},
7};
8
9/// Represents a cached JWKS entry for a tenant/provider pair.
10#[derive(Clone, Debug)]
11pub struct CacheEntry {
12	tenant_id: Arc<str>,
13	provider_id: Arc<str>,
14	state: CacheState,
15}
16impl CacheEntry {
17	/// Create a new empty cache entry.
18	pub fn new(tenant_id: impl Into<Arc<str>>, provider_id: impl Into<Arc<str>>) -> Self {
19		Self {
20			tenant_id: tenant_id.into(),
21			provider_id: provider_id.into(),
22			state: CacheState::Empty,
23		}
24	}
25
26	/// Tenant identifier for this cache entry.
27	pub fn tenant_id(&self) -> &str {
28		&self.tenant_id
29	}
30
31	/// Provider identifier for this cache entry.
32	pub fn provider_id(&self) -> &str {
33		&self.provider_id
34	}
35
36	/// Inspect the current cache state.
37	pub fn state(&self) -> &CacheState {
38		&self.state
39	}
40
41	/// Attempt to begin an initial load; returns false when already loading or ready.
42	pub fn begin_load(&mut self) -> bool {
43		match self.state {
44			CacheState::Empty => {
45				self.state = CacheState::Loading;
46
47				true
48			},
49			_ => false,
50		}
51	}
52
53	/// Record a successful load or refresh, updating state to `Ready`.
54	pub fn load_success(&mut self, mut payload: CachePayload) {
55		payload.reset_failures();
56		self.state = CacheState::Ready(payload);
57	}
58
59	/// Attempt to transition into refreshing state when scheduled refresh is due.
60	pub fn begin_refresh(&mut self, now: Instant) -> bool {
61		match &mut self.state {
62			CacheState::Ready(payload) =>
63				if now >= payload.next_refresh_at {
64					let next = payload.clone();
65					self.state = CacheState::Refreshing(next);
66
67					true
68				} else {
69					false
70				},
71			CacheState::Refreshing(_) | CacheState::Loading | CacheState::Empty => false,
72		}
73	}
74
75	/// Record a successful refresh.
76	pub fn refresh_success(&mut self, mut payload: CachePayload) {
77		payload.reset_failures();
78		self.state = CacheState::Ready(payload);
79	}
80
81	/// Record a refresh failure and decide whether stale data can remain active.
82	///
83	/// When a backoff is provided the next refresh instant is shifted forward
84	/// by that duration, effectively treating it as a cooldown on top of the
85	/// previously scheduled refresh window.
86	pub fn refresh_failure(&mut self, now: Instant, next_backoff: Option<Duration>) {
87		self.state = match std::mem::replace(&mut self.state, CacheState::Empty) {
88			CacheState::Refreshing(mut payload) => {
89				payload.bump_error(next_backoff);
90
91				if let Some(delay) = next_backoff {
92					payload.next_refresh_at = now + delay;
93				}
94
95				if payload.can_serve_stale(now) {
96					CacheState::Ready(payload)
97				} else {
98					CacheState::Empty
99				}
100			},
101			state => state,
102		};
103	}
104
105	/// Invalidate the cached payload, returning to Empty state.
106	pub fn invalidate(&mut self) {
107		self.state = CacheState::Empty;
108	}
109
110	/// Retrieve a clone of the cached payload if present.
111	pub fn snapshot(&self) -> Option<CachePayload> {
112		self.state.payload().cloned()
113	}
114}
115
116#[cfg(test)]
117mod tests {
118	// crates.io
119	use http::{Request, Response, StatusCode};
120	use http_cache_semantics::CachePolicy;
121	use jsonwebtoken::jwk::JwkSet;
122	// self
123	use super::*;
124
125	fn sample_payload(now: Instant) -> CachePayload {
126		let request = Request::builder()
127			.method("GET")
128			.uri("https://example.com/.well-known/jwks.json")
129			.body(())
130			.expect("request");
131		let response = Response::builder().status(StatusCode::OK).body(()).expect("response");
132		let policy = CachePolicy::new(&request, &response);
133
134		CachePayload {
135			jwks: Arc::new(JwkSet { keys: Vec::new() }),
136			policy,
137			etag: Some("v1".to_string()),
138			last_modified: None,
139			last_refresh_at: Utc::now(),
140			expires_at: now + Duration::from_secs(60),
141			next_refresh_at: now + Duration::from_secs(30),
142			stale_deadline: Some(now + Duration::from_secs(120)),
143			retry_backoff: None,
144			error_count: 0,
145		}
146	}
147
148	#[test]
149	fn load_success_moves_entry_into_ready_state() {
150		let mut entry = CacheEntry::new("tenant", "provider");
151
152		assert!(matches!(entry.state(), CacheState::Empty));
153		assert!(entry.begin_load());
154
155		let now = Instant::now();
156		let payload = sample_payload(now);
157
158		entry.load_success(payload.clone());
159
160		match entry.state() {
161			CacheState::Ready(meta) => {
162				assert_eq!(meta.etag.as_deref(), Some("v1"));
163				assert_eq!(meta.error_count, 0);
164				assert!(meta.expires_at > now);
165			},
166			other => panic!("expected Ready state, got {:?}", other),
167		}
168	}
169
170	#[test]
171	fn begin_refresh_moves_ready_to_refreshing() {
172		let mut entry = CacheEntry::new("tenant", "provider");
173
174		entry.begin_load();
175
176		let now = Instant::now();
177
178		entry.load_success(sample_payload(now));
179
180		assert!(entry.begin_refresh(now + Duration::from_secs(31)));
181		matches!(entry.state(), CacheState::Refreshing(_));
182	}
183
184	#[test]
185	fn refresh_failure_without_stale_deadline_clears_entry() {
186		let mut entry = CacheEntry::new("tenant", "provider");
187
188		entry.begin_load();
189
190		let now = Instant::now();
191		let mut payload = sample_payload(now);
192
193		payload.stale_deadline = None;
194		entry.load_success(payload);
195
196		assert!(entry.begin_refresh(now + Duration::from_secs(31)));
197
198		entry.refresh_failure(now + Duration::from_secs(90), None);
199
200		assert!(matches!(entry.state(), CacheState::Empty));
201	}
202}