use std::fmt;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use crate::Error;
#[derive(Clone)]
pub struct RetryBudget {
shared: Arc<Shared>,
}
impl RetryBudget {
pub const DEFAULT_CAPACITY: u32 = 500;
pub(crate) const DEFAULT_RETRY_COST: u32 = 14;
pub(crate) const DEFAULT_THROTTLING_RETRY_COST: u32 = 5;
pub(crate) const DEFAULT_SUCCESS_REFUND: u32 = 1;
#[must_use]
pub fn new() -> Self {
Self::from_config(Config {
capacity: Self::DEFAULT_CAPACITY,
retry_cost: Self::DEFAULT_RETRY_COST,
throttling_retry_cost: Self::DEFAULT_THROTTLING_RETRY_COST,
success_refund: Self::DEFAULT_SUCCESS_REFUND,
})
}
#[must_use]
pub fn capacity(self, capacity: u32) -> Self {
self.reconfigure(|config| config.capacity = capacity)
}
#[cfg(test)]
pub(crate) fn retry_cost(self, cost: u32) -> Self {
self.reconfigure(|config| config.retry_cost = cost)
}
#[cfg(test)]
pub(crate) fn throttling_retry_cost(self, cost: u32) -> Self {
self.reconfigure(|config| config.throttling_retry_cost = cost)
}
#[cfg(test)]
pub(crate) fn success_refund(self, refund: u32) -> Self {
self.reconfigure(|config| config.success_refund = refund)
}
#[must_use]
pub fn available(&self) -> u32 {
self.shared.available.load(Ordering::Relaxed)
}
#[must_use = "dropping the permit forfeits the refund a successful retry earns"]
pub fn try_acquire(&self, error: &Error) -> Option<RetryPermit> {
let cost = self.shared.config.cost(error);
self.update_available(|available| available.checked_sub(cost))
.then_some(RetryPermit { cost })
}
pub fn record_success(&self, last_retry: Option<RetryPermit>) {
let refund = last_retry.map_or(self.shared.config.success_refund, |permit| permit.cost);
let capacity = self.shared.config.capacity;
self.update_available(|available| Some(available.saturating_add(refund).min(capacity)));
}
fn update_available(&self, mut update: impl FnMut(u32) -> Option<u32>) -> bool {
let mut available = self.shared.available.load(Ordering::Relaxed);
while let Some(next) = update(available) {
match self.shared.available.compare_exchange_weak(
available,
next,
Ordering::Relaxed,
Ordering::Relaxed,
) {
Ok(_) => return true,
Err(current) => available = current,
}
}
false
}
fn from_config(config: Config) -> Self {
Self {
shared: Arc::new(Shared {
available: AtomicU32::new(config.capacity),
config,
}),
}
}
fn reconfigure(self, change: impl FnOnce(&mut Config)) -> Self {
let mut config = self.shared.config;
change(&mut config);
Self::from_config(config)
}
}
impl Default for RetryBudget {
fn default() -> Self {
Self::new()
}
}
impl fmt::Debug for RetryBudget {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let config = &self.shared.config;
f.debug_struct("RetryBudget")
.field("available", &self.available())
.field("capacity", &config.capacity)
.field("retry_cost", &config.retry_cost)
.field("throttling_retry_cost", &config.throttling_retry_cost)
.field("success_refund", &config.success_refund)
.finish()
}
}
pub struct RetryPermit {
cost: u32,
}
impl RetryPermit {
#[cfg(test)]
pub(crate) fn cost(&self) -> u32 {
self.cost
}
}
impl fmt::Debug for RetryPermit {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("RetryPermit").finish_non_exhaustive()
}
}
struct Shared {
config: Config,
available: AtomicU32,
}
#[derive(Clone, Copy)]
struct Config {
capacity: u32,
retry_cost: u32,
throttling_retry_cost: u32,
success_refund: u32,
}
impl Config {
fn cost(&self, error: &Error) -> u32 {
if matches!(error, Error::RateLimited { .. }) {
self.throttling_retry_cost
} else {
self.retry_cost
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn starts_full_with_aws_defaults() {
let budget = RetryBudget::new();
assert_eq!(budget.available(), 500);
let permit = budget.try_acquire(&Error::overloaded("busy")).unwrap();
assert_eq!(permit.cost(), 14);
let permit = budget.try_acquire(&Error::rate_limited("slow")).unwrap();
assert_eq!(permit.cost(), 5);
assert_eq!(budget.available(), 500 - 14 - 5);
}
#[test]
fn a_failover_ending_in_a_rate_limit_costs_a_throttling_retry() {
let budget = RetryBudget::new();
let error = Error::rate_limited("slow down").with_fallback_from(Error::timeout("slow"));
assert_eq!(budget.try_acquire(&error).unwrap().cost(), 5);
}
#[test]
fn denies_a_retry_it_cannot_afford() {
let budget = RetryBudget::new().capacity(20);
assert!(budget.try_acquire(&Error::timeout("a")).is_some());
assert_eq!(budget.available(), 6);
assert!(budget.try_acquire(&Error::timeout("b")).is_none());
assert_eq!(budget.available(), 6);
assert!(budget.try_acquire(&Error::rate_limited("c")).is_some());
assert_eq!(budget.available(), 1);
}
#[test]
fn successes_refund_per_the_aws_rules() {
let budget = RetryBudget::new().capacity(100);
let permit = budget.try_acquire(&Error::timeout("x")).unwrap();
assert_eq!(budget.available(), 86);
budget.record_success(Some(permit));
assert_eq!(budget.available(), 100);
let _ = budget.try_acquire(&Error::timeout("x")).unwrap();
budget.record_success(None);
assert_eq!(budget.available(), 87);
let full = RetryBudget::new().capacity(10);
full.record_success(None);
assert_eq!(full.available(), 10);
full.record_success(Some(RetryPermit { cost: 14 }));
assert_eq!(full.available(), 10);
}
#[test]
fn clones_share_tokens_and_setters_start_afresh() {
let budget = RetryBudget::new();
let clone = budget.clone();
let _ = clone.try_acquire(&Error::timeout("x")).unwrap();
assert_eq!(budget.available(), 486);
let reconfigured = budget.clone().retry_cost(1);
assert_eq!(reconfigured.available(), 500);
let _ = reconfigured.try_acquire(&Error::timeout("x")).unwrap();
assert_eq!(reconfigured.available(), 499);
assert_eq!(budget.available(), 486);
}
#[test]
fn costs_and_refund_are_configurable() {
let budget = RetryBudget::new()
.capacity(10)
.retry_cost(3)
.throttling_retry_cost(2)
.success_refund(4);
assert_eq!(budget.try_acquire(&Error::timeout("x")).unwrap().cost(), 3);
assert_eq!(
budget
.try_acquire(&Error::rate_limited("x"))
.unwrap()
.cost(),
2
);
assert_eq!(budget.available(), 5);
budget.record_success(None);
assert_eq!(budget.available(), 9);
}
#[test]
fn concurrent_withdrawals_never_overdraw() {
let budget = RetryBudget::new().capacity(14 * 10);
let granted: usize = std::thread::scope(|scope| {
let handles: Vec<_> = (0..8)
.map(|_| {
let budget = budget.clone();
scope.spawn(move || {
(0..10)
.filter(|_| budget.try_acquire(&Error::timeout("x")).is_some())
.count()
})
})
.collect();
handles.into_iter().map(|h| h.join().unwrap()).sum()
});
assert_eq!(granted, 10);
assert_eq!(budget.available(), 0);
}
#[test]
fn debug_shows_tokens_and_settings() {
let debug = format!("{:?}", RetryBudget::new());
assert!(debug.contains("available: 500"), "{debug}");
assert!(debug.contains("retry_cost: 14"), "{debug}");
}
}