use std::collections::HashMap;
use std::sync::{Arc, PoisonError};
use crate::sync_shim::{AtomicU64, Mutex, Ordering};
use crate::config::RetryBudgetConfig;
const SCALE: u64 = 1_000;
pub const MAX_HOSTS: usize = 1_024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RetryKind {
Throttling,
Transient,
}
#[derive(Debug)]
pub struct RetryBudget {
milli_tokens: AtomicU64,
capacity: u64,
transient_cost: u64,
throttling_cost: u64,
refill: u64,
}
impl RetryBudget {
#[must_use]
pub fn new(config: &RetryBudgetConfig) -> Self {
let capacity = u64::from(config.capacity).saturating_mul(SCALE);
let transient_cost = u64::from(config.transient_cost).saturating_mul(SCALE);
#[allow(
clippy::cast_possible_truncation,
clippy::cast_sign_loss,
clippy::cast_precision_loss,
reason = "the ratio is clamped to [0, 1]; the cast saturates"
)]
let refill = (config.retry_ratio.clamp(0.0, 1.0) * transient_cost as f64).round() as u64;
Self {
milli_tokens: AtomicU64::new(capacity),
capacity,
transient_cost,
throttling_cost: u64::from(config.throttling_cost).saturating_mul(SCALE),
refill,
}
}
pub fn record_request(&self) {
self.add(self.refill);
}
#[must_use]
pub fn try_acquire(&self, kind: RetryKind) -> bool {
let cost = self.cost(kind);
self.update(|tokens| tokens.checked_sub(cost))
}
pub fn release(&self, kind: RetryKind) {
self.add(self.cost(kind));
}
#[cfg(test)]
#[must_use]
#[allow(clippy::cast_precision_loss, reason = "a display value")]
pub fn available(&self) -> f64 {
self.milli_tokens.load(Ordering::Acquire) as f64 / SCALE as f64
}
const fn cost(&self, kind: RetryKind) -> u64 {
match kind {
RetryKind::Throttling => self.throttling_cost,
RetryKind::Transient => self.transient_cost,
}
}
fn add(&self, amount: u64) {
self.update(|tokens| Some(tokens.saturating_add(amount).min(self.capacity)));
}
fn update(&self, next: impl Fn(u64) -> Option<u64>) -> bool {
let mut current = self.milli_tokens.load(Ordering::Acquire);
loop {
let Some(value) = next(current) else {
return false;
};
match self.milli_tokens.compare_exchange_weak(
current,
value,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => return true,
Err(actual) => current = actual,
}
}
}
}
#[derive(Debug)]
pub struct RetryBudgets {
config: RetryBudgetConfig,
hosts: Mutex<HashMap<String, Arc<RetryBudget>>>,
}
impl RetryBudgets {
#[must_use]
pub fn new(config: &RetryBudgetConfig) -> Self {
Self {
config: config.clone(),
hosts: Mutex::new(HashMap::new()),
}
}
#[must_use]
pub fn for_host(&self, host: &str) -> Arc<RetryBudget> {
let key = host.to_ascii_lowercase();
let mut hosts = self.hosts.lock().unwrap_or_else(PoisonError::into_inner);
if let Some(budget) = hosts.get(&key) {
return Arc::clone(budget);
}
let budget = Arc::new(RetryBudget::new(&self.config));
if hosts.len() < MAX_HOSTS {
hosts.insert(key, Arc::clone(&budget));
}
budget
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config() -> RetryBudgetConfig {
RetryBudgetConfig::default()
}
fn drain(budget: &RetryBudget, kind: RetryKind) -> u32 {
let mut count = 0;
while budget.try_acquire(kind) {
count += 1;
}
count
}
#[test]
fn a_full_bucket_allows_capacity_over_cost_retries() {
let budget = RetryBudget::new(&config());
assert_eq!(drain(&budget, RetryKind::Transient), 500 / 14);
let budget = RetryBudget::new(&config());
assert_eq!(drain(&budget, RetryKind::Throttling), 500 / 5);
}
#[test]
fn an_empty_bucket_refills_at_the_retry_ratio() {
let budget = RetryBudget::new(&config());
drain(&budget, RetryKind::Transient);
let mut retries = 0;
for _ in 0..1_000 {
budget.record_request();
if budget.try_acquire(RetryKind::Transient) {
retries += 1;
}
}
assert!((95..=105).contains(&retries), "about 10 %: {retries}");
}
#[test]
fn the_bucket_never_goes_above_capacity() {
let budget = RetryBudget::new(&config());
for _ in 0..10_000 {
budget.record_request();
}
assert!((budget.available() - 500.0).abs() < f64::EPSILON);
}
#[test]
fn a_successful_retry_gives_its_tokens_back() {
let budget = RetryBudget::new(&config());
assert!(budget.try_acquire(RetryKind::Transient));
assert!((budget.available() - 486.0).abs() < f64::EPSILON);
budget.release(RetryKind::Transient);
assert!((budget.available() - 500.0).abs() < f64::EPSILON);
}
#[test]
fn hosts_have_their_own_bucket() {
let budgets = RetryBudgets::new(&config());
let a = budgets.for_host("a.example");
assert!(Arc::ptr_eq(&a, &budgets.for_host("A.Example")));
drain(&a, RetryKind::Transient);
assert!(
budgets
.for_host("b.example")
.try_acquire(RetryKind::Transient)
);
}
#[test]
fn hosts_past_the_limit_get_a_full_unstored_bucket() {
let budgets = RetryBudgets::new(&config());
for index in 0..MAX_HOSTS {
let _ = budgets.for_host(&format!("h{index}"));
}
let late = budgets.for_host("late");
drain(&late, RetryKind::Transient);
let again = budgets.for_host("late");
assert!(!Arc::ptr_eq(&late, &again), "not stored");
assert!(again.try_acquire(RetryKind::Transient), "a full bucket");
assert!(budgets.for_host("other").try_acquire(RetryKind::Transient));
assert_eq!(budgets.hosts.lock().unwrap().len(), MAX_HOSTS);
}
}