use ahash::AHasher;
use arc_swap::ArcSwap;
use chrono::{DateTime, Utc};
use futures::channel::oneshot;
use futures::future::{Either, select};
use std::hash::Hasher;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use crate::PolicyStoreConfig;
use crate::PolicyStoreSource;
use crate::async_sleep::sleep;
use crate::authz::Authz;
use crate::authz::metrics::MetricsCollector;
use crate::bootstrap_config::{AuthorizationConfig, JwtConfig};
use crate::common::policy_store::{PolicyStoreWithID, TrustedIssuer};
use crate::context_data_api::DataStore;
use crate::http::cache_headers::CacheHeadersState;
use crate::http::{ConditionalFetch, HeadOutcome, HttpClient};
use crate::http::{JoinHandle, spawn_task};
use crate::log::interface::LogWriter;
use crate::log::{BaseLogEntry, LogEntry, LogLevel, Logger};
use super::authz_builder::{BuildAuthzError, build_authz};
use super::policy_store::{
PolicyStoreLoadError, ZIP_MAGIC, parse_cjar_bytes, parse_lock_master_bytes,
};
const REFRESH_FAILURE_BACKOFF_MAX_SECS: u64 = 600;
const STRATEGY_DEGRADE_THRESHOLD: u32 = 3;
const STRATEGY_REPROBE_INTERVAL_SECS: i64 = 1800;
#[repr(i64)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RefreshOutcome {
Success = 1,
NotModified = 2,
HttpError = 3,
NetworkError = 4,
ParseError = 5,
RebuildError = 6,
DecodeError = 7,
}
impl RefreshOutcome {
pub(crate) fn metric_value(self) -> i64 {
self as i64
}
}
#[repr(i64)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RefreshStrategy {
Conditional = 1,
HeadThenGet = 2,
PlainGet = 3,
}
impl RefreshStrategy {
pub(crate) fn metric_value(self) -> i64 {
self as i64
}
}
struct StrategyState {
current: RefreshStrategy,
degraded_count: u32,
last_probe_at: Option<DateTime<Utc>>,
conditional_to_head_transitions: u32,
head_to_plain_transitions: u32,
upgrade_to_head_transitions: u32,
upgrade_to_conditional_transitions: u32,
}
impl Default for StrategyState {
fn default() -> Self {
Self {
current: RefreshStrategy::Conditional,
degraded_count: 0,
last_probe_at: None,
conditional_to_head_transitions: 0,
head_to_plain_transitions: 0,
upgrade_to_head_transitions: 0,
upgrade_to_conditional_transitions: 0,
}
}
}
struct StrategyChoice {
strategy: RefreshStrategy,
probe_target: Option<RefreshStrategy>,
}
impl StrategyState {
fn choose_for_tick(&mut self) -> StrategyChoice {
if self.current == RefreshStrategy::Conditional {
return StrategyChoice {
strategy: RefreshStrategy::Conditional,
probe_target: None,
};
}
let now = Utc::now();
let due = self.last_probe_at.is_none_or(|t| {
now.signed_duration_since(t).num_seconds() >= STRATEGY_REPROBE_INTERVAL_SECS
});
if due {
self.last_probe_at = Some(now);
let target = match self.current {
RefreshStrategy::HeadThenGet => RefreshStrategy::Conditional,
RefreshStrategy::PlainGet => RefreshStrategy::HeadThenGet,
RefreshStrategy::Conditional => unreachable!(),
};
StrategyChoice {
strategy: target,
probe_target: Some(target),
}
} else {
StrategyChoice {
strategy: self.current,
probe_target: None,
}
}
}
fn record_degraded(&mut self) {
self.degraded_count = self.degraded_count.saturating_add(1);
if self.degraded_count < STRATEGY_DEGRADE_THRESHOLD {
return;
}
self.degraded_count = 0;
self.degrade_one_step();
}
fn record_helped(&mut self) {
self.degraded_count = 0;
}
fn force_degrade(&mut self) {
self.degraded_count = 0;
self.degrade_one_step();
}
fn degrade_one_step(&mut self) {
match self.current {
RefreshStrategy::Conditional => {
self.current = RefreshStrategy::HeadThenGet;
self.conditional_to_head_transitions =
self.conditional_to_head_transitions.saturating_add(1);
self.last_probe_at = Some(Utc::now());
},
RefreshStrategy::HeadThenGet => {
self.current = RefreshStrategy::PlainGet;
self.head_to_plain_transitions = self.head_to_plain_transitions.saturating_add(1);
self.last_probe_at = Some(Utc::now());
},
RefreshStrategy::PlainGet => {},
}
}
fn record_probe_success(&mut self) {
if self.current != RefreshStrategy::Conditional {
self.current = RefreshStrategy::Conditional;
self.degraded_count = 0;
self.upgrade_to_conditional_transitions =
self.upgrade_to_conditional_transitions.saturating_add(1);
}
}
fn record_head_probe_success(&mut self) {
if self.current == RefreshStrategy::PlainGet {
self.current = RefreshStrategy::HeadThenGet;
self.upgrade_to_head_transitions = self.upgrade_to_head_transitions.saturating_add(1);
}
}
}
pub(crate) struct AuthzRebuilder {
pub(crate) jwt_config: JwtConfig,
pub(crate) authorization_config: AuthorizationConfig,
pub(crate) http_client: HttpClient,
pub(crate) log: Logger,
pub(crate) data_store: Arc<DataStore>,
pub(crate) metrics: Arc<MetricsCollector>,
}
impl AuthzRebuilder {
pub(crate) async fn rebuild(
&self,
policy_store: PolicyStoreWithID,
prior_authz: &Authz,
) -> Result<Authz, RebuildError> {
let prior_jwt = if issuers_unchanged(
prior_authz.trusted_issuers(),
policy_store.trusted_issuers.as_ref(),
) {
Some(prior_authz.clone_jwt_service())
} else {
None
};
build_authz(
policy_store,
&self.jwt_config,
&self.authorization_config,
self.http_client.clone(),
&self.log,
self.data_store.clone(),
self.metrics.clone(),
prior_jwt,
)
.await
}
}
fn issuers_unchanged(
prior: Option<&std::collections::HashMap<String, TrustedIssuer>>,
new: Option<&std::collections::HashMap<String, TrustedIssuer>>,
) -> bool {
prior == new
}
pub(crate) type RebuildError = BuildAuthzError;
#[derive(Default)]
struct RefreshState {
validators: CacheHeadersState,
last_body_hash: Option<u64>,
consecutive_failures: u32,
strategy: StrategyState,
}
impl RefreshState {
fn next_delay(&self, base_secs: u64) -> Duration {
let min_secs = PolicyStoreConfig::MIN_REFRESH_INTERVAL_SECS;
let base_secs = base_secs.max(min_secs);
let server_fresh = self.validators.fresh_for.map(|d| d.as_secs());
let mut secs = match server_fresh {
Some(s) if s > 0 => s.min(base_secs),
_ => base_secs,
};
if self.consecutive_failures > 0 {
let shift = self.consecutive_failures.min(10);
let exp = 1u64.checked_shl(shift).unwrap_or(u64::MAX);
secs = secs
.saturating_mul(exp)
.min(REFRESH_FAILURE_BACKOFF_MAX_SECS);
}
let secs = secs.max(min_secs);
let jitter_pct = jitter_pct();
let secs_i128 = i128::from(secs);
let adjusted = secs_i128 + (secs_i128 * jitter_pct / 100);
let adjusted = adjusted.max(i128::from(min_secs));
Duration::from_secs(u64::try_from(adjusted).unwrap_or(min_secs))
}
}
fn jitter_pct() -> i128 {
static COUNTER: AtomicU64 = AtomicU64::new(0);
let n = COUNTER.fetch_add(1, Ordering::Relaxed);
i128::from(n % 21) - 10
}
pub(crate) fn body_hash(bytes: &[u8]) -> u64 {
let mut h = AHasher::default();
h.write(bytes);
h.finish()
}
fn should_short_circuit(new_hash: u64, state: &RefreshState) -> bool {
Some(new_hash) == state.last_body_hash
}
#[derive(Debug, Clone)]
pub(crate) enum RefreshSource {
LockServer { url: String },
CjarUrl { url: String },
Uri { url: String },
}
impl RefreshSource {
pub(crate) fn from_policy_store_source(src: &PolicyStoreSource) -> Option<Self> {
match src {
PolicyStoreSource::LockServer(u) => Some(Self::LockServer { url: u.clone() }),
PolicyStoreSource::CjarUrl(u) => Some(Self::CjarUrl { url: u.clone() }),
PolicyStoreSource::Uri(u) => Some(Self::Uri { url: u.clone() }),
_ => None,
}
}
fn url(&self) -> &str {
match self {
Self::LockServer { url } | Self::CjarUrl { url } | Self::Uri { url } => url,
}
}
async fn parse(
&self,
bytes: &[u8],
strict_schema_validation: bool,
) -> Result<PolicyStoreWithID, PolicyStoreLoadError> {
if bytes.starts_with(&ZIP_MAGIC) {
return parse_cjar_bytes(bytes, strict_schema_validation).await;
}
match self {
Self::LockServer { .. } | Self::Uri { .. } => {
parse_lock_master_bytes(bytes, strict_schema_validation)
},
Self::CjarUrl { .. } => parse_cjar_bytes(bytes, strict_schema_validation).await,
}
}
}
pub(crate) struct PolicyStoreRefreshHandle {
shutdown: Option<oneshot::Sender<()>>,
_join: JoinHandle<()>,
}
impl Drop for PolicyStoreRefreshHandle {
fn drop(&mut self) {
drop(self.shutdown.take());
}
}
pub(crate) struct RefreshWorkerSeed {
pub initial_body_hash: Option<u64>,
pub initial_validators: CacheHeadersState,
}
pub(crate) struct WorkerContext {
pub(crate) source: RefreshSource,
pub(crate) interval_secs: u64,
pub(crate) http_client: HttpClient,
pub(crate) rebuilder: AuthzRebuilder,
pub(crate) authz_swap: Arc<ArcSwap<Authz>>,
pub(crate) metrics: Arc<MetricsCollector>,
pub(crate) log: Logger,
pub(crate) initial_body_hash: Option<u64>,
pub(crate) initial_validators: CacheHeadersState,
pub(crate) strict_schema_validation: bool,
}
pub(crate) fn spawn_refresh_worker(ctx: WorkerContext) -> PolicyStoreRefreshHandle {
let (shutdown_tx, shutdown_rx) = oneshot::channel::<()>();
let join = spawn_task(async move {
run_worker(ctx, shutdown_rx).await;
});
PolicyStoreRefreshHandle {
shutdown: Some(shutdown_tx),
_join: join,
}
}
async fn run_worker(ctx: WorkerContext, shutdown_rx: oneshot::Receiver<()>) {
let mut state = RefreshState {
last_body_hash: ctx.initial_body_hash,
validators: ctx.initial_validators.clone(),
..RefreshState::default()
};
let mut shutdown_rx = shutdown_rx;
ctx.log.log_any(
LogEntry::new(BaseLogEntry::new_system_opt_request_id(
LogLevel::INFO,
None,
))
.set_message(format!(
"policy store refresh worker started (url={}, interval={}s)",
ctx.source.url(),
ctx.interval_secs,
)),
);
loop {
let delay = state.next_delay(ctx.interval_secs);
let sleep_fut = sleep(delay);
futures::pin_mut!(sleep_fut);
match select(sleep_fut, &mut shutdown_rx).await {
Either::Left(((), _)) => {
let outcome = tick(&ctx, &mut state).await;
ctx.metrics.record_policy_store_refresh(outcome);
ctx.metrics.record_policy_store_refresh_strategy(
state.strategy.current,
state.strategy.conditional_to_head_transitions,
state.strategy.head_to_plain_transitions,
state.strategy.upgrade_to_head_transitions,
state.strategy.upgrade_to_conditional_transitions,
);
},
Either::Right(_) => {
ctx.log.log_any(
LogEntry::new(BaseLogEntry::new_system_opt_request_id(
LogLevel::DEBUG,
None,
))
.set_message("policy store refresh worker shutting down".to_string()),
);
break;
},
}
}
}
async fn tick(ctx: &WorkerContext, state: &mut RefreshState) -> RefreshOutcome {
let choice = state.strategy.choose_for_tick();
let is_probe = choice.probe_target.is_some();
match choice.strategy {
RefreshStrategy::Conditional => tick_conditional(ctx, state, is_probe).await,
RefreshStrategy::HeadThenGet => tick_head_then_get(ctx, state, is_probe).await,
RefreshStrategy::PlainGet => tick_plain_get(ctx, state).await,
}
}
async fn tick_conditional(
ctx: &WorkerContext,
state: &mut RefreshState,
is_probe: bool,
) -> RefreshOutcome {
let url = ctx.source.url();
let fetch_result = ctx
.http_client
.get_bytes_conditional(url, &state.validators)
.await;
match fetch_result {
Err(e) => fetch_error(ctx, state, &e),
Ok(ConditionalFetch::NotModified { validators }) => {
state.validators.merge_from(validators);
state.consecutive_failures = 0;
if is_probe {
state.strategy.record_probe_success();
} else {
state.strategy.record_helped();
}
RefreshOutcome::NotModified
},
Ok(ConditionalFetch::Modified { bytes, validators }) => {
let new_hash = body_hash(&bytes);
if should_short_circuit(new_hash, state) {
state.validators.merge_from(validators);
state.consecutive_failures = 0;
if !is_probe {
state.strategy.record_degraded();
}
return RefreshOutcome::NotModified;
}
parse_swap_and_record(ctx, state, bytes, new_hash, validators).await
},
}
}
async fn tick_head_then_get(
ctx: &WorkerContext,
state: &mut RefreshState,
is_probe: bool,
) -> RefreshOutcome {
let url = ctx.source.url();
let head = match ctx.http_client.head_validators(url).await {
Ok(h) => h,
Err(e) => return fetch_error(ctx, state, &e),
};
match head {
HeadOutcome::NotSupported => {
if !is_probe {
state.strategy.force_degrade();
}
tick_plain_get(ctx, state).await
},
HeadOutcome::Headers(new_validators) => {
if !new_validators.has_validator() {
if !is_probe {
state.strategy.record_degraded();
}
return tick_plain_get(ctx, state).await;
}
if validators_match(&new_validators, &state.validators) {
state.validators.merge_from(new_validators);
state.consecutive_failures = 0;
if is_probe {
state.strategy.record_head_probe_success();
} else {
state.strategy.record_helped();
}
return RefreshOutcome::NotModified;
}
let outcome = tick_plain_get(ctx, state).await;
if matches!(
outcome,
RefreshOutcome::Success | RefreshOutcome::NotModified
) {
if is_probe {
state.strategy.record_head_probe_success();
} else {
state.strategy.record_helped();
}
}
outcome
},
}
}
async fn tick_plain_get(ctx: &WorkerContext, state: &mut RefreshState) -> RefreshOutcome {
let url = ctx.source.url();
let response = match ctx.http_client.get_with_retry(url).await {
Ok(r) => r,
Err(e) => return fetch_error(ctx, state, &e),
};
let status = response.status();
let new_validators = CacheHeadersState::from_headers(response.headers(), Utc::now());
let bytes = match ctx.http_client.read_response_capped(response).await {
Ok(b) => b,
Err(e) => {
state.consecutive_failures = state.consecutive_failures.saturating_add(1);
ctx.log.log_any(
LogEntry::new(BaseLogEntry::new_system_opt_request_id(
LogLevel::WARN,
None,
))
.set_message(format!(
"policy store refresh: body decode error for {url}: {e} (status {status})"
)),
);
return RefreshOutcome::DecodeError;
},
};
let new_hash = body_hash(&bytes);
if should_short_circuit(new_hash, state) {
state.validators.merge_from(new_validators);
state.consecutive_failures = 0;
return RefreshOutcome::NotModified;
}
parse_swap_and_record(ctx, state, bytes, new_hash, new_validators).await
}
async fn parse_swap_and_record(
ctx: &WorkerContext,
state: &mut RefreshState,
bytes: Vec<u8>,
new_hash: u64,
new_validators: CacheHeadersState,
) -> RefreshOutcome {
let url = ctx.source.url();
let start = Utc::now();
let parsed = match ctx.source.parse(&bytes, ctx.strict_schema_validation).await {
Ok(p) => p,
Err(e) => {
state.consecutive_failures = state.consecutive_failures.saturating_add(1);
ctx.log.log_any(
LogEntry::new(BaseLogEntry::new_system_opt_request_id(
LogLevel::ERROR,
None,
))
.set_message(format!(
"policy store refresh: parse failure for {url}: {e}"
)),
);
return RefreshOutcome::ParseError;
},
};
let new_policy_count = parsed.policies.get_set().num_of_policies();
let prior_authz = ctx.authz_swap.load_full();
let new_authz = match ctx.rebuilder.rebuild(parsed, &prior_authz).await {
Ok(a) => a,
Err(e) => {
state.consecutive_failures = state.consecutive_failures.saturating_add(1);
ctx.log.log_any(
LogEntry::new(BaseLogEntry::new_system_opt_request_id(
LogLevel::ERROR,
None,
))
.set_message(format!(
"policy store refresh: rebuild failure for {url}: {e}"
)),
);
return RefreshOutcome::RebuildError;
},
};
ctx.authz_swap.store(Arc::new(new_authz));
ctx.metrics.set_policy_count(new_policy_count);
state.validators = new_validators;
state.last_body_hash = Some(new_hash);
state.consecutive_failures = 0;
let elapsed = Utc::now().signed_duration_since(start).num_milliseconds();
ctx.log.log_any(
LogEntry::new(BaseLogEntry::new_system_opt_request_id(
LogLevel::INFO,
None,
))
.set_message(format!(
"policy store refresh: swapped new store from {url} in {elapsed} ms"
)),
);
RefreshOutcome::Success
}
fn fetch_error(
ctx: &WorkerContext,
state: &mut RefreshState,
e: &crate::http::HttpClientError,
) -> RefreshOutcome {
let url = ctx.source.url();
state.consecutive_failures = state.consecutive_failures.saturating_add(1);
let (kind, outcome) = classify_fetch_error(e);
ctx.log.log_any(
LogEntry::new(BaseLogEntry::new_system_opt_request_id(
LogLevel::WARN,
None,
))
.set_message(format!("policy store refresh: {kind} against {url}: {e}")),
);
outcome
}
fn classify_fetch_error(e: &crate::http::HttpClientError) -> (&'static str, RefreshOutcome) {
if e.is_decode_error() {
("body decode error", RefreshOutcome::DecodeError)
} else if e.status_code().is_some() {
("HTTP error", RefreshOutcome::HttpError)
} else if e.is_max_retries_exceeded() {
("network error", RefreshOutcome::NetworkError)
} else {
("HTTP error", RefreshOutcome::HttpError)
}
}
fn validators_match(a: &CacheHeadersState, b: &CacheHeadersState) -> bool {
if let (Some(ea), Some(eb)) = (a.etag.as_ref(), b.etag.as_ref()) {
return etag_opaque_tag(ea) == etag_opaque_tag(eb);
}
if let (Some(la), Some(lb)) = (a.last_modified.as_ref(), b.last_modified.as_ref()) {
return la == lb;
}
false
}
fn etag_opaque_tag(etag: &str) -> &str {
etag.strip_prefix("W/").unwrap_or(etag)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn choose_for_tick_from_plain_get_probes_head_then_get() {
let mut state = StrategyState {
current: RefreshStrategy::PlainGet,
last_probe_at: None,
..StrategyState::default()
};
let choice = state.choose_for_tick();
assert_eq!(choice.strategy, RefreshStrategy::HeadThenGet);
assert!(choice.probe_target.is_some());
}
#[test]
fn choose_for_tick_from_head_then_get_probes_conditional() {
let mut state = StrategyState {
current: RefreshStrategy::HeadThenGet,
last_probe_at: None,
..StrategyState::default()
};
let choice = state.choose_for_tick();
assert_eq!(choice.strategy, RefreshStrategy::Conditional);
assert!(choice.probe_target.is_some());
}
#[test]
fn choose_for_tick_no_probe_when_cooldown_active() {
let mut state = StrategyState {
current: RefreshStrategy::PlainGet,
last_probe_at: Some(Utc::now()),
..StrategyState::default()
};
let choice = state.choose_for_tick();
assert_eq!(choice.strategy, RefreshStrategy::PlainGet);
assert!(choice.probe_target.is_none());
}
#[test]
fn record_head_probe_success_upgrades_plain_get_to_head_then_get() {
let mut state = StrategyState {
current: RefreshStrategy::PlainGet,
..StrategyState::default()
};
state.record_head_probe_success();
assert_eq!(state.current, RefreshStrategy::HeadThenGet);
assert_eq!(state.upgrade_to_head_transitions, 1);
}
#[test]
fn record_head_probe_success_is_noop_from_head_then_get() {
let mut state = StrategyState {
current: RefreshStrategy::HeadThenGet,
..StrategyState::default()
};
state.record_head_probe_success();
assert_eq!(state.current, RefreshStrategy::HeadThenGet);
assert_eq!(state.upgrade_to_head_transitions, 0);
}
fn one_issuer() -> HashMap<String, TrustedIssuer> {
let mut m = HashMap::new();
m.insert("issuer_a".to_string(), TrustedIssuer::default());
m
}
#[test]
fn identical_issuer_maps_signal_reuse() {
let a = one_issuer();
let b = one_issuer();
assert!(
issuers_unchanged(Some(&a), Some(&b)),
"identical issuer maps must signal reuse so JwtService is not rebuilt",
);
}
#[test]
fn empty_vs_some_issuer_signals_rebuild() {
let o = one_issuer();
assert!(
!issuers_unchanged(None, Some(&o)),
"going from no issuers to some issuers must signal a JwtService rebuild",
);
assert!(
!issuers_unchanged(Some(&o), None),
"removing all issuers must signal a JwtService rebuild",
);
}
#[test]
fn added_issuer_signals_rebuild() {
let base = one_issuer();
let mut expanded = one_issuer();
expanded.insert("issuer_b".to_string(), TrustedIssuer::default());
assert!(
!issuers_unchanged(Some(&base), Some(&expanded)),
"adding a new issuer must signal a JwtService rebuild",
);
}
#[test]
fn removed_issuer_signals_rebuild() {
let mut two = one_issuer();
two.insert("issuer_b".to_string(), TrustedIssuer::default());
let one = one_issuer();
assert!(
!issuers_unchanged(Some(&two), Some(&one)),
"removing an issuer must signal a JwtService rebuild",
);
}
#[test]
fn next_delay_uses_base_when_no_server_hint() {
let s = RefreshState::default();
let d = s.next_delay(300).as_secs();
assert!((270..=330).contains(&d), "got {d}");
}
#[test]
fn next_delay_honors_shorter_server_hint() {
let s = RefreshState {
validators: CacheHeadersState {
fresh_for: Some(Duration::from_secs(60)),
..CacheHeadersState::default()
},
..RefreshState::default()
};
let d = s.next_delay(300).as_secs();
assert!((54..=66).contains(&d), "got {d}");
}
#[test]
fn next_delay_caps_server_hint_at_base() {
let s = RefreshState {
validators: CacheHeadersState {
fresh_for: Some(Duration::from_secs(3600)),
..CacheHeadersState::default()
},
..RefreshState::default()
};
let d = s.next_delay(30).as_secs();
assert!((27..=33).contains(&d), "got {d}");
}
#[test]
fn next_delay_clamped_above_min() {
let s = RefreshState::default();
let d = s.next_delay(1).as_secs();
assert!(d >= PolicyStoreConfig::MIN_REFRESH_INTERVAL_SECS, "got {d}");
}
#[test]
fn next_delay_backs_off_on_failures() {
let s = RefreshState {
consecutive_failures: 3,
..RefreshState::default()
};
let d = s.next_delay(60).as_secs();
assert!((400..=600).contains(&d), "got {d}");
}
#[test]
fn body_hash_stable_for_same_bytes() {
let a = body_hash(b"hello");
let b = body_hash(b"hello");
assert_eq!(
a, b,
"body_hash must be stable for identical byte slices — the short-circuit relies on byte-equal bodies producing the same hash within a process",
);
}
#[test]
fn body_hash_differs_for_different_bytes() {
assert_ne!(
body_hash(b"hello"),
body_hash(b"world"),
"body_hash must differ for different byte slices — otherwise the short-circuit would skip rebuilds when the policy store actually changed",
);
}
#[test]
fn refresh_state_seed_short_circuits_first_tick_on_identical_body() {
let bytes = b"identical-policy-store-body";
let state = RefreshState {
last_body_hash: Some(body_hash(bytes)),
..RefreshState::default()
};
assert!(
should_short_circuit(body_hash(bytes), &state),
"seeded state must short-circuit when the upstream returns the same bytes (would otherwise pay one wasted parse/rebuild on every process startup)",
);
assert!(
!should_short_circuit(body_hash(b"different-body"), &state),
"different bytes must NOT short-circuit — first tick has to fall through to parse + rebuild + swap so policy updates land",
);
}
#[test]
fn should_short_circuit_returns_false_on_unseeded_state() {
let state = RefreshState::default();
assert!(
!should_short_circuit(body_hash(b"anything"), &state),
"with no seed, no hash can match — first tick must always fall through",
);
}
#[test]
fn body_hash_short_circuit_merges_validators_rather_than_overwriting() {
let mut state = RefreshState {
last_body_hash: Some(body_hash(b"unchanged body")),
validators: CacheHeadersState {
etag: Some("\"v1\"".to_string()),
last_modified: Some("Mon".to_string()),
fresh_for: Some(Duration::from_secs(60)),
..CacheHeadersState::default()
},
..RefreshState::default()
};
let response_with_no_etag = CacheHeadersState {
last_modified: Some("Mon".to_string()),
..CacheHeadersState::default()
};
state.validators.merge_from(response_with_no_etag);
assert_eq!(
state.validators.etag.as_deref(),
Some("\"v1\""),
"ETag must survive a 200-identical-body that drops the field — otherwise the next conditional GET has nothing to send and we'd re-download forever",
);
assert_eq!(
state.validators.last_modified.as_deref(),
Some("Mon"),
"Last-Modified must survive on the short-circuit path",
);
assert_eq!(
state.validators.fresh_for, None,
"fresh_for is per-response — a response that omits Cache-Control must clear it (Finding #5)",
);
}
#[test]
fn refresh_source_construction_from_url() {
let s =
RefreshSource::from_policy_store_source(&PolicyStoreSource::CjarUrl("http://x".into()));
assert!(
matches!(s, Some(RefreshSource::CjarUrl { .. })),
"PolicyStoreSource::CjarUrl must map to RefreshSource::CjarUrl, got {s:?}",
);
let s = RefreshSource::from_policy_store_source(&PolicyStoreSource::LockServer(
"http://y".into(),
));
assert!(
matches!(s, Some(RefreshSource::LockServer { .. })),
"PolicyStoreSource::LockServer must map to RefreshSource::LockServer, got {s:?}",
);
let s = RefreshSource::from_policy_store_source(&PolicyStoreSource::Uri("http://z".into()));
assert!(
matches!(s, Some(RefreshSource::Uri { .. })),
"PolicyStoreSource::Uri must map to RefreshSource::Uri, got {s:?}",
);
let s = RefreshSource::from_policy_store_source(&PolicyStoreSource::Yaml("..".into()));
assert!(
s.is_none(),
"non-URL sources (Yaml/Json/file paths/archive bytes) must produce no RefreshSource — the worker doesn't spawn for them, got {s:?}",
);
}
#[test]
fn strategy_starts_at_conditional() {
let s = StrategyState::default();
assert_eq!(
s.current,
RefreshStrategy::Conditional,
"fresh StrategyState must start at the most efficient strategy",
);
}
#[test]
fn strategy_degrades_after_threshold() {
let mut s = StrategyState::default();
for i in 0..STRATEGY_DEGRADE_THRESHOLD - 1 {
s.record_degraded();
assert_eq!(
s.current,
RefreshStrategy::Conditional,
"degrade {} of {STRATEGY_DEGRADE_THRESHOLD} must not yet transition — threshold is consecutive ticks",
i + 1,
);
}
s.record_degraded();
assert_eq!(
s.current,
RefreshStrategy::HeadThenGet,
"exactly STRATEGY_DEGRADE_THRESHOLD consecutive degrades must transition Conditional → HeadThenGet",
);
assert_eq!(
s.conditional_to_head_transitions, 1,
"transition counter must increment exactly once per Conditional → HeadThenGet",
);
for _ in 0..STRATEGY_DEGRADE_THRESHOLD {
s.record_degraded();
}
assert_eq!(
s.current,
RefreshStrategy::PlainGet,
"another threshold's worth of degrades must transition HeadThenGet → PlainGet",
);
assert_eq!(
s.head_to_plain_transitions, 1,
"head_to_plain_transitions counter must increment exactly once per HeadThenGet → PlainGet",
);
}
#[test]
fn strategy_record_helped_resets_degrade_counter() {
let mut s = StrategyState::default();
s.record_degraded();
s.record_degraded();
s.record_helped();
s.record_degraded();
assert_eq!(
s.current,
RefreshStrategy::Conditional,
"record_helped must reset the degrade counter so the next single degrade does not transition",
);
}
#[test]
fn strategy_force_degrade_skips_threshold() {
let mut s = StrategyState::default();
s.force_degrade();
assert_eq!(
s.current,
RefreshStrategy::HeadThenGet,
"force_degrade must transition immediately, without waiting for the threshold",
);
assert_eq!(
s.conditional_to_head_transitions, 1,
"force_degrade must increment the transition counter the same as a threshold-triggered degrade",
);
}
#[test]
fn strategy_probe_success_upgrades_back_to_conditional() {
let mut s = StrategyState {
current: RefreshStrategy::PlainGet,
..StrategyState::default()
};
s.record_probe_success();
assert_eq!(
s.current,
RefreshStrategy::Conditional,
"a successful Conditional probe must restore the most efficient strategy",
);
assert_eq!(
s.upgrade_to_conditional_transitions, 1,
"upgrade_to_conditional_transitions must increment exactly once per upgrade",
);
}
#[test]
fn strategy_choose_returns_conditional_when_current() {
let mut s = StrategyState::default();
let choice = s.choose_for_tick();
assert_eq!(
choice.strategy,
RefreshStrategy::Conditional,
"choose_for_tick must return the current strategy when already at Conditional",
);
assert!(
choice.probe_target.is_none(),
"is_probe must be false when not probing — we're already at the most efficient strategy",
);
}
#[test]
fn strategy_choose_first_probe_when_degraded() {
let mut s = StrategyState {
current: RefreshStrategy::HeadThenGet,
..StrategyState::default()
};
let choice = s.choose_for_tick();
assert_eq!(
choice.strategy,
RefreshStrategy::Conditional,
"a first choose_for_tick after a degrade must run a Conditional probe to test for upstream recovery",
);
assert!(
choice.probe_target.is_some(),
"is_probe must be true so tick logic interprets 304 as an upgrade signal rather than a steady-state result",
);
assert!(
s.last_probe_at.is_some(),
"choose_for_tick must stamp last_probe_at when scheduling a probe so the reprobe interval can be enforced",
);
}
#[test]
fn strategy_choose_returns_current_between_probes() {
let mut s = StrategyState {
current: RefreshStrategy::PlainGet,
last_probe_at: Some(Utc::now()),
..StrategyState::default()
};
let choice = s.choose_for_tick();
assert_eq!(
choice.strategy,
RefreshStrategy::PlainGet,
"while inside the reprobe interval window, choose_for_tick must return the current strategy unchanged",
);
assert!(
choice.probe_target.is_none(),
"no probe is scheduled inside the reprobe window — is_probe must be false",
);
}
#[test]
fn validators_match_etag_takes_precedence() {
let a = CacheHeadersState {
etag: Some("\"v1\"".to_string()),
last_modified: Some("Mon".to_string()),
..CacheHeadersState::default()
};
let b = CacheHeadersState {
etag: Some("\"v1\"".to_string()),
last_modified: Some("Tue".to_string()),
..CacheHeadersState::default()
};
assert!(validators_match(&a, &b), "matching ETag wins");
}
#[test]
fn validators_match_falls_back_to_last_modified() {
let a = CacheHeadersState {
last_modified: Some("Mon".to_string()),
..CacheHeadersState::default()
};
let b = CacheHeadersState {
last_modified: Some("Mon".to_string()),
..CacheHeadersState::default()
};
assert!(
validators_match(&a, &b),
"with no ETag on either side, equal Last-Modified must match",
);
}
#[test]
fn validators_match_returns_false_when_neither_present() {
let a = CacheHeadersState::default();
let b = CacheHeadersState::default();
assert!(
!validators_match(&a, &b),
"with no validators on either side there is nothing to compare — must return false rather than treat absence as agreement",
);
}
#[test]
fn validators_match_returns_false_on_different_etag() {
let a = CacheHeadersState {
etag: Some("\"v1\"".to_string()),
..CacheHeadersState::default()
};
let b = CacheHeadersState {
etag: Some("\"v2\"".to_string()),
..CacheHeadersState::default()
};
assert!(
!validators_match(&a, &b),
"different ETags must not match — short-circuit would skip a real update",
);
}
#[test]
fn validators_match_falls_back_to_last_modified_when_etag_only_on_one_side() {
let a = CacheHeadersState {
etag: Some("\"v1\"".to_string()),
last_modified: Some("Mon".to_string()),
..CacheHeadersState::default()
};
let b = CacheHeadersState {
etag: None,
last_modified: Some("Mon".to_string()),
..CacheHeadersState::default()
};
assert!(
validators_match(&a, &b),
"ETag must be on BOTH sides to compare; asymmetric presence must fall back to Last-Modified",
);
}
#[test]
fn validators_match_returns_false_when_etag_differs_even_if_lm_matches() {
let a = CacheHeadersState {
etag: Some("\"v1\"".to_string()),
last_modified: Some("Mon".to_string()),
..CacheHeadersState::default()
};
let b = CacheHeadersState {
etag: Some("\"v2\"".to_string()),
last_modified: Some("Mon".to_string()),
..CacheHeadersState::default()
};
assert!(
!validators_match(&a, &b),
"ETag mismatch is decisive — matching Last-Modified must not rescue the comparison",
);
}
#[test]
fn validators_match_returns_false_on_different_last_modified() {
let a = CacheHeadersState {
last_modified: Some("Mon, 01 Jan 2024 00:00:00 GMT".to_string()),
..CacheHeadersState::default()
};
let b = CacheHeadersState {
last_modified: Some("Tue, 02 Jan 2024 00:00:00 GMT".to_string()),
..CacheHeadersState::default()
};
assert!(
!validators_match(&a, &b),
"different Last-Modified values must not match",
);
}
#[test]
fn validators_match_weak_etag_equals_strong_for_same_tag() {
let weak = CacheHeadersState {
etag: Some("W/\"v1\"".to_string()),
..CacheHeadersState::default()
};
let strong = CacheHeadersState {
etag: Some("\"v1\"".to_string()),
..CacheHeadersState::default()
};
assert!(
validators_match(&weak, &strong),
"weak `W/\"v1\"` and strong `\"v1\"` must match under If-None-Match semantics (RFC 7232 §2.3.2)",
);
assert!(
validators_match(&strong, &weak),
"comparison must be symmetric — strong vs weak must also match",
);
}
#[test]
fn validators_match_weak_etags_match_each_other() {
let a = CacheHeadersState {
etag: Some("W/\"v1\"".to_string()),
..CacheHeadersState::default()
};
let b = CacheHeadersState {
etag: Some("W/\"v1\"".to_string()),
..CacheHeadersState::default()
};
assert!(
validators_match(&a, &b),
"two identical weak ETags must match",
);
}
#[test]
fn validators_match_weak_etag_still_distinguishes_different_tags() {
let a = CacheHeadersState {
etag: Some("W/\"v1\"".to_string()),
..CacheHeadersState::default()
};
let b = CacheHeadersState {
etag: Some("W/\"v2\"".to_string()),
..CacheHeadersState::default()
};
assert!(
!validators_match(&a, &b),
"different opaque tags must not match even when both are weak",
);
}
#[test]
fn validators_match_returns_false_when_lm_present_on_one_side_only() {
let a = CacheHeadersState {
last_modified: Some("Mon".to_string()),
..CacheHeadersState::default()
};
let b = CacheHeadersState::default();
assert!(
!validators_match(&a, &b),
"Last-Modified on only one side cannot match — asymmetric presence must not be treated as agreement",
);
}
#[test]
fn strategy_plain_get_does_not_degrade_further() {
let mut s = StrategyState {
current: RefreshStrategy::PlainGet,
..StrategyState::default()
};
for _ in 0..STRATEGY_DEGRADE_THRESHOLD * 3 {
s.record_degraded();
}
assert_eq!(
s.current,
RefreshStrategy::PlainGet,
"PlainGet is the floor — repeated record_degraded() must not move below it",
);
assert_eq!(
s.head_to_plain_transitions, 0,
"no head→plain transition can fire from PlainGet",
);
assert_eq!(
s.conditional_to_head_transitions, 0,
"no conditional→head transition can fire from PlainGet",
);
}
#[test]
fn strategy_force_degrade_at_plain_get_is_noop() {
let mut s = StrategyState {
current: RefreshStrategy::PlainGet,
..StrategyState::default()
};
s.force_degrade();
assert_eq!(
s.current,
RefreshStrategy::PlainGet,
"force_degrade at the floor must leave current unchanged",
);
assert_eq!(
s.conditional_to_head_transitions, 0,
"no conditional→head transition can fire when force_degrade is invoked at the PlainGet floor",
);
assert_eq!(
s.head_to_plain_transitions, 0,
"no head→plain transition can fire when force_degrade is invoked at the PlainGet floor",
);
}
#[test]
fn strategy_probe_success_noop_when_already_conditional() {
let mut s = StrategyState::default();
assert_eq!(
s.current,
RefreshStrategy::Conditional,
"default StrategyState must start at Conditional",
);
s.record_probe_success();
assert_eq!(
s.current,
RefreshStrategy::Conditional,
"record_probe_success while already at Conditional must be a no-op",
);
assert_eq!(
s.upgrade_to_conditional_transitions, 0,
"no upgrade transition counter increment when already at Conditional",
);
}
#[test]
fn strategy_record_helped_does_not_change_current() {
let mut s = StrategyState {
current: RefreshStrategy::HeadThenGet,
..StrategyState::default()
};
s.record_helped();
assert_eq!(
s.current,
RefreshStrategy::HeadThenGet,
"record_helped() only resets the degrade counter — current strategy must not change",
);
}
#[test]
fn strategy_cumulative_transitions_accumulate() {
let mut s = StrategyState::default();
for _ in 0..3 {
for _ in 0..STRATEGY_DEGRADE_THRESHOLD {
s.record_degraded();
}
s.record_probe_success();
}
assert_eq!(
s.current,
RefreshStrategy::Conditional,
"after each cycle the probe must restore Conditional",
);
assert_eq!(
s.conditional_to_head_transitions, 3,
"each conditional→head transition must increment the counter cumulatively, not overwrite",
);
assert_eq!(
s.upgrade_to_conditional_transitions, 3,
"each upgrade-back-to-Conditional must increment cumulatively",
);
}
#[test]
fn strategy_full_walk_down() {
let mut s = StrategyState::default();
for _ in 0..STRATEGY_DEGRADE_THRESHOLD {
s.record_degraded();
}
assert_eq!(
s.current,
RefreshStrategy::HeadThenGet,
"threshold-many degrades from Conditional must land on HeadThenGet",
);
for _ in 0..STRATEGY_DEGRADE_THRESHOLD {
s.record_degraded();
}
assert_eq!(
s.current,
RefreshStrategy::PlainGet,
"another threshold-many degrades must reach the PlainGet floor",
);
assert_eq!(
s.conditional_to_head_transitions, 1,
"exactly one conditional→head transition expected on the full walk",
);
assert_eq!(
s.head_to_plain_transitions, 1,
"exactly one head→plain transition expected on the full walk",
);
}
#[test]
fn strategy_degrade_counter_resets_on_transition() {
let mut s = StrategyState::default();
for _ in 0..STRATEGY_DEGRADE_THRESHOLD {
s.record_degraded();
}
assert_eq!(
s.current,
RefreshStrategy::HeadThenGet,
"first transition lands on HeadThenGet",
);
s.record_degraded();
assert_eq!(
s.current,
RefreshStrategy::HeadThenGet,
"single post-transition degrade must NOT fall straight through to PlainGet — counter must reset on transition",
);
}
#[test]
fn strategy_choose_does_not_advance_probe_clock_when_conditional() {
let mut s = StrategyState::default();
let _ = s.choose_for_tick();
assert!(
s.last_probe_at.is_none(),
"last_probe_at must remain None while at Conditional (no probe to schedule), got {:?}",
s.last_probe_at,
);
}
#[test]
fn strategy_choose_does_not_reset_degrade_counter() {
let mut s = StrategyState {
current: RefreshStrategy::HeadThenGet,
degraded_count: 2,
..StrategyState::default()
};
let _ = s.choose_for_tick();
assert_eq!(
s.degraded_count, 2,
"choose_for_tick must not mutate degraded_count — that's only changed by record_degraded / record_helped",
);
}
#[test]
fn body_hash_handles_empty_bytes() {
let h1 = body_hash(b"");
let h2 = body_hash(b"");
assert_eq!(
h1, h2,
"empty bytes must hash consistently within a process — otherwise the short-circuit would never fire for empty responses",
);
}
#[test]
fn body_hash_differs_between_empty_and_single_byte() {
assert_ne!(
body_hash(b""),
body_hash(b"\0"),
"empty bytes and a single NUL byte must hash to different values — otherwise the short-circuit would conflate them",
);
}
#[test]
fn body_hash_sensitive_to_byte_order() {
assert_ne!(
body_hash(b"ab"),
body_hash(b"ba"),
"body_hash must be order-sensitive — same-multiset different-order inputs must hash differently or the short-circuit would skip real changes",
);
}
#[test]
fn next_delay_zero_fresh_for_falls_through_to_base() {
let s = RefreshState {
validators: CacheHeadersState {
fresh_for: Some(Duration::ZERO),
..CacheHeadersState::default()
},
..RefreshState::default()
};
let d = s.next_delay(60).as_secs();
assert!((54..=66).contains(&d), "got {d}");
}
#[test]
fn next_delay_saturates_under_maximum_failures() {
let s = RefreshState {
consecutive_failures: u32::MAX,
..RefreshState::default()
};
let d = s.next_delay(60).as_secs();
assert!(d <= 660, "got {d}");
}
#[test]
fn next_delay_huge_base_is_bounded_by_no_failure_path() {
let s = RefreshState::default();
let d = s.next_delay(u64::MAX / 1024).as_secs();
assert!(d > 0, "got {d}");
}
#[test]
fn classify_max_retries_exceeded_without_status_routes_to_network_error() {
use crate::http_utils::{HttpRequestError, HttpRequestReasonError};
let err = HttpRequestError::new(HttpRequestReasonError::MaxRetriesExceeded, None);
let (kind, outcome) = classify_fetch_error(&err);
assert_eq!(
kind, "network error",
"MaxRetriesExceeded with no status must classify as a transport-level network error",
);
assert_eq!(
outcome,
RefreshOutcome::NetworkError,
"no-status MaxRetriesExceeded must surface as NetworkError, got {outcome:?}",
);
}
#[test]
fn classify_max_retries_exceeded_with_status_routes_to_http_error() {
use crate::http_utils::{HttpRequestError, HttpRequestReasonError};
let err = HttpRequestError::new(
HttpRequestReasonError::MaxRetriesExceeded,
Some(reqwest::StatusCode::NOT_FOUND),
);
let (kind, outcome) = classify_fetch_error(&err);
assert_eq!(
kind, "HTTP error",
"MaxRetriesExceeded carrying a status code means the upstream replied — classify as HttpError",
);
assert_eq!(
outcome,
RefreshOutcome::HttpError,
"retry-exhausted 4xx/5xx must surface as HttpError, not NetworkError, got {outcome:?}",
);
}
#[test]
fn classify_http_status_error_routes_to_http_error() {
use crate::http_utils::{HttpRequestError, HttpRequestReasonError};
let err = HttpRequestError::new(
HttpRequestReasonError::HttpStatusError,
Some(reqwest::StatusCode::INTERNAL_SERVER_ERROR),
);
let (kind, outcome) = classify_fetch_error(&err);
assert_eq!(kind, "HTTP error");
assert_eq!(
outcome,
RefreshOutcome::HttpError,
"HttpStatusError variant must surface as HttpError, got {outcome:?}",
);
}
}