use crate::Headers;
use time::{Duration, OffsetDateTime};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RateLimit {
pub limit: Option<u64>,
pub remaining: Option<u64>,
pub reset_at: Option<OffsetDateTime>,
pub retry_after: Option<Duration>,
}
impl RateLimit {
pub fn from_headers(headers: &Headers, now: OffsetDateTime) -> Self {
let limit = headers
.get_u64("ratelimit-limit")
.or_else(|| headers.get_u64("x-ratelimit-limit"));
let remaining = headers
.get_u64("ratelimit-remaining")
.or_else(|| headers.get_u64("x-ratelimit-remaining"));
let retry_after = headers
.get_u64("retry-after")
.map(|seconds| Duration::seconds(seconds as i64));
let reset_at =
Self::reset_at(headers, now).or_else(|| retry_after.map(|after| now + after));
Self {
limit,
remaining,
reset_at,
retry_after,
}
}
fn reset_at(headers: &Headers, now: OffsetDateTime) -> Option<OffsetDateTime> {
const TIMESTAMP_THRESHOLD: u64 = 1_000_000_000;
let value = headers
.get_u64("x-ratelimit-reset")
.or_else(|| headers.get_u64("ratelimit-reset"))?;
if value >= TIMESTAMP_THRESHOLD {
OffsetDateTime::from_unix_timestamp(value as i64).ok()
} else {
Some(now + Duration::seconds(value as i64))
}
}
pub fn is_exhausted(&self) -> bool {
self.remaining == Some(0)
}
pub fn wait_for(&self, now: OffsetDateTime) -> Option<Duration> {
if let Some(retry_after) = self.retry_after {
return Some(retry_after);
}
if !self.is_exhausted() {
return None;
}
self.reset_at
.map(|reset_at| (reset_at - now).max(Duration::ZERO))
}
pub fn is_present(&self) -> bool {
self.limit.is_some() || self.remaining.is_some() || self.retry_after.is_some()
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::datetime;
const NOW: OffsetDateTime = datetime!(2026-08-23 10:00 UTC);
#[test]
fn github_style_headers_are_understood() {
let reset = (NOW + Duration::minutes(30)).unix_timestamp().to_string();
let headers = Headers::from_iter([
("x-ratelimit-limit", "5000".to_owned()),
("x-ratelimit-remaining", "0".to_owned()),
("x-ratelimit-reset", reset),
]);
let limit = RateLimit::from_headers(&headers, NOW);
assert_eq!(limit.limit, Some(5000));
assert!(limit.is_exhausted());
assert_eq!(limit.wait_for(NOW), Some(Duration::minutes(30)));
}
#[test]
fn ietf_style_reset_is_relative() {
let headers = Headers::from_iter([("ratelimit-remaining", "0"), ("ratelimit-reset", "60")]);
let limit = RateLimit::from_headers(&headers, NOW);
assert_eq!(limit.reset_at, Some(NOW + Duration::seconds(60)));
assert_eq!(limit.wait_for(NOW), Some(Duration::seconds(60)));
}
#[test]
fn retry_after_wins_over_a_reset_window() {
let headers = Headers::from_iter([
("ratelimit-remaining", "0"),
("ratelimit-reset", "600"),
("retry-after", "20"),
]);
assert_eq!(
RateLimit::from_headers(&headers, NOW).wait_for(NOW),
Some(Duration::seconds(20))
);
}
#[test]
fn a_healthy_budget_asks_for_no_wait() {
let headers = Headers::from_iter([("x-ratelimit-remaining", "4999")]);
assert_eq!(RateLimit::from_headers(&headers, NOW).wait_for(NOW), None);
}
#[test]
fn a_response_without_rate_limit_headers_is_reported_as_absent() {
assert!(!RateLimit::from_headers(&Headers::new(), NOW).is_present());
}
}