use std::time::Duration;
use chrono::{DateTime, Utc};
const DEFAULT_SAFETY_MARGIN_SECS: u64 = 60;
const DEFAULT_REFRESH_WINDOW_SECS: u64 = 60;
const MIN_EFFECTIVE_TTL_SECS: u64 = 5;
pub fn safety_margin_secs() -> u64 {
use std::sync::OnceLock;
static M: OnceLock<u64> = OnceLock::new();
*M.get_or_init(|| {
std::env::var("KEYCHAIN_CACHE_DYNAMIC_SAFETY_MARGIN_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(DEFAULT_SAFETY_MARGIN_SECS)
})
}
pub fn refresh_window_secs() -> u64 {
use std::sync::OnceLock;
static M: OnceLock<u64> = OnceLock::new();
*M.get_or_init(|| {
std::env::var("KEYCHAIN_CACHE_REFRESH_WINDOW_SECS")
.ok()
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(DEFAULT_REFRESH_WINDOW_SECS)
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheDecision {
CacheFor(u64),
SkipCacheAlreadyExpired,
}
pub fn cache_decision(
expires_at: Option<DateTime<Utc>>,
default_ttl: Duration,
safety_margin: Duration,
now: DateTime<Utc>,
) -> CacheDecision {
let Some(expires_at) = expires_at else {
return CacheDecision::CacheFor(default_ttl.as_secs());
};
let safety =
chrono::Duration::from_std(safety_margin).unwrap_or_else(|_| chrono::Duration::seconds(0));
let effective_deadline = expires_at - safety;
if effective_deadline <= now {
return CacheDecision::SkipCacheAlreadyExpired;
}
let remaining = (effective_deadline - now).num_seconds().max(0) as u64;
let capped = remaining
.min(default_ttl.as_secs())
.max(MIN_EFFECTIVE_TTL_SECS);
CacheDecision::CacheFor(capped)
}
pub fn effective_cache_ttl(
expires_at: Option<DateTime<Utc>>,
default_ttl: Duration,
now: DateTime<Utc>,
) -> CacheDecision {
cache_decision(
expires_at,
default_ttl,
Duration::from_secs(safety_margin_secs()),
now,
)
}
pub fn should_refresh(
expires_at: Option<DateTime<Utc>>,
refresh_window: Duration,
now: DateTime<Utc>,
) -> bool {
let Some(expires_at) = expires_at else {
return false;
};
if expires_at <= now {
return false;
}
let window = chrono::Duration::from_std(refresh_window)
.unwrap_or_else(|_| chrono::Duration::seconds(0));
expires_at - window <= now
}
pub fn should_refresh_default(
expires_at: Option<DateTime<Utc>>,
now: DateTime<Utc>,
) -> bool {
should_refresh(
expires_at,
Duration::from_secs(refresh_window_secs()),
now,
)
}
#[cfg(test)]
mod tests {
use super::*;
fn at(secs: i64) -> DateTime<Utc> {
DateTime::<Utc>::from_timestamp(secs, 0).unwrap()
}
#[test]
fn no_expires_at_uses_default_ttl() {
let d = cache_decision(
None,
Duration::from_secs(600),
Duration::from_secs(60),
at(1000),
);
assert_eq!(d, CacheDecision::CacheFor(600));
}
#[test]
fn expires_at_far_future_is_capped_at_default_ttl() {
let now = at(1000);
let expires_at = at(10_000);
let d = cache_decision(
Some(expires_at),
Duration::from_secs(600),
Duration::from_secs(60),
now,
);
assert_eq!(d, CacheDecision::CacheFor(600));
}
#[test]
fn expires_at_near_future_uses_expires_minus_margin() {
let now = at(1000);
let expires_at = at(1000 + 300);
let d = cache_decision(
Some(expires_at),
Duration::from_secs(600),
Duration::from_secs(60),
now,
);
assert_eq!(d, CacheDecision::CacheFor(240));
}
#[test]
fn expires_at_already_past_skips_cache() {
let now = at(1000);
let expires_at = at(900);
let d = cache_decision(
Some(expires_at),
Duration::from_secs(600),
Duration::from_secs(60),
now,
);
assert_eq!(d, CacheDecision::SkipCacheAlreadyExpired);
}
#[test]
fn expires_at_inside_safety_margin_skips_cache() {
let now = at(1000);
let expires_at = at(1000 + 30);
let d = cache_decision(
Some(expires_at),
Duration::from_secs(600),
Duration::from_secs(60),
now,
);
assert_eq!(d, CacheDecision::SkipCacheAlreadyExpired);
}
#[test]
fn very_small_remaining_clamped_to_min_ttl() {
let now = at(1000);
let expires_at = at(1000 + 70);
let d = cache_decision(
Some(expires_at),
Duration::from_secs(600),
Duration::from_secs(60),
now,
);
assert_eq!(d, CacheDecision::CacheFor(10));
let expires_at = at(1000 + 62);
let d = cache_decision(
Some(expires_at),
Duration::from_secs(600),
Duration::from_secs(60),
now,
);
assert_eq!(d, CacheDecision::CacheFor(5));
}
#[test]
fn zero_safety_margin_treats_expires_at_as_hard_deadline() {
let now = at(1000);
let d = cache_decision(
Some(now),
Duration::from_secs(600),
Duration::from_secs(0),
now,
);
assert_eq!(d, CacheDecision::SkipCacheAlreadyExpired);
}
#[test]
fn should_refresh_returns_false_when_no_expires_at() {
assert!(!should_refresh(None, Duration::from_secs(60), at(1000)));
}
#[test]
fn should_refresh_returns_false_when_already_expired() {
let now = at(1000);
let expires_at = at(990); assert!(!should_refresh(
Some(expires_at),
Duration::from_secs(60),
now
));
}
#[test]
fn should_refresh_returns_false_when_outside_window() {
let now = at(1000);
let expires_at = at(2000); assert!(!should_refresh(
Some(expires_at),
Duration::from_secs(60),
now
));
}
#[test]
fn should_refresh_returns_true_inside_window() {
let now = at(1000);
let expires_at = at(1030);
assert!(should_refresh(
Some(expires_at),
Duration::from_secs(60),
now
));
}
#[test]
fn should_refresh_at_exact_window_boundary() {
let now = at(1000);
let expires_at = at(1060);
assert!(should_refresh(
Some(expires_at),
Duration::from_secs(60),
now
));
}
}