use core::num::{NonZeroU32, NonZeroU64};
use core::time::Duration;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct RateLimit {
limit: NonZeroU32,
window: Duration,
}
impl RateLimit {
#[must_use]
pub const fn new(limit: NonZeroU32, window: Duration) -> Self {
assert!(
!window.is_zero(),
"a rate-limit window of zero refuses everything"
);
Self { limit, window }
}
#[must_use]
pub const fn limit(&self) -> NonZeroU32 {
self.limit
}
#[must_use]
pub const fn window(&self) -> Duration {
self.window
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Verdict {
Allowed {
remaining: NonZeroU32,
},
Limited {
retry_after: NonZeroU64,
},
}
#[must_use]
pub fn check(limit: &RateLimit, seen: u32, elapsed: Duration) -> Verdict {
if elapsed >= limit.window {
return Verdict::Allowed {
remaining: limit.limit,
};
}
if seen >= limit.limit.get() {
let left = limit
.window
.checked_sub(elapsed)
.unwrap_or(Duration::from_secs(0));
return Verdict::Limited {
retry_after: seconds_ceil(left),
};
}
Verdict::Allowed {
remaining: NonZeroU32::new(limit.limit.get() - seen).unwrap_or(limit.limit),
}
}
fn seconds_ceil(duration: Duration) -> NonZeroU64 {
if duration.is_zero() {
return NonZeroU64::MIN;
}
let millis = u64::try_from(duration.as_millis()).unwrap_or(u64::MAX);
let seconds = millis / 1000;
let rounded = if millis.is_multiple_of(1000) {
seconds
} else {
seconds + 1
};
NonZeroU64::new(rounded).unwrap_or(NonZeroU64::MIN)
}
#[cfg(test)]
mod tests {
use assert_matches::assert_matches;
use core::num::NonZeroU32;
use core::time::Duration;
use super::{RateLimit, Verdict, check, seconds_ceil};
type TestError = Box<dyn std::error::Error>;
fn nz(value: u32) -> Result<NonZeroU32, TestError> {
NonZeroU32::new(value).ok_or_else(|| "a fixture asked for a zero limit".into())
}
fn limit() -> Result<RateLimit, TestError> {
Ok(RateLimit::new(nz(3)?, Duration::from_secs(60)))
}
#[test]
fn requests_under_the_limit_are_allowed_and_count_down() -> Result<(), TestError> {
let limit = limit()?;
assert_eq!(
check(&limit, 0, Duration::from_secs(1)),
Verdict::Allowed { remaining: nz(3)? }
);
assert_eq!(
check(&limit, 2, Duration::from_secs(1)),
Verdict::Allowed { remaining: nz(1)? }
);
Ok(())
}
#[test]
fn the_window_boundary_decides_both_ways() -> Result<(), TestError> {
let limit = limit()?;
assert_matches!(
check(&limit, 3, Duration::from_secs(59)),
Verdict::Limited { .. }
);
assert_eq!(
check(&limit, 3, Duration::from_secs(60)),
Verdict::Allowed { remaining: nz(3)? }
);
Ok(())
}
#[test]
fn a_count_beyond_the_limit_is_refused_not_wrapped() -> Result<(), TestError> {
let limit = limit()?;
assert_matches!(
check(&limit, u32::MAX, Duration::from_secs(1)),
Verdict::Limited { .. }
);
Ok(())
}
#[test]
fn a_refusal_always_asks_the_caller_to_wait() -> Result<(), TestError> {
let limit = limit()?;
for elapsed in [0, 1, 30, 59] {
let Verdict::Limited { retry_after } = check(&limit, 3, Duration::from_secs(elapsed))
else {
return Err("a spent window must refuse".into());
};
assert!(
retry_after.get() > 0,
"Retry-After: 0 tells the client to retry immediately"
);
}
Ok(())
}
#[test]
fn a_limit_of_one_permits_exactly_one() -> Result<(), TestError> {
let limit = RateLimit::new(nz(1)?, Duration::from_secs(10));
assert_eq!(
check(&limit, 0, Duration::from_secs(1)),
Verdict::Allowed { remaining: nz(1)? }
);
assert_matches!(
check(&limit, 1, Duration::from_secs(1)),
Verdict::Limited { .. }
);
Ok(())
}
#[test]
fn seconds_round_up_and_never_reach_zero() {
assert_eq!(seconds_ceil(Duration::from_millis(999)).get(), 1);
assert_eq!(seconds_ceil(Duration::from_millis(1000)).get(), 1);
assert_eq!(seconds_ceil(Duration::from_millis(1001)).get(), 2);
assert_eq!(seconds_ceil(Duration::from_secs(60)).get(), 60);
assert_eq!(seconds_ceil(Duration::ZERO).get(), 1);
}
}