use async_trait::async_trait;
use std::sync::Arc;
use std::time::Duration;
use cratefield_core::{Decision, Quota, RateLimitError, RateLimiter};
use worker::D1Database as WorkerD1;
use worker::send::IntoSendFuture;
pub const RATE_LIMIT_COUNTERS_SQL: &str = "CREATE TABLE IF NOT EXISTS rate_limit_counters (\
key TEXT PRIMARY KEY, \
window_start INTEGER NOT NULL, \
count INTEGER NOT NULL);";
const UPSERT_SQL: &str = "INSERT INTO rate_limit_counters \
(key, window_start, count) VALUES (?1, ?2, 1) \
ON CONFLICT(key) DO UPDATE SET \
count = CASE WHEN window_start = excluded.window_start THEN count + 1 ELSE 1 END, \
window_start = excluded.window_start \
RETURNING count, window_start";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Limit {
pub max: u32,
pub period: Duration,
}
pub type RateLimitPolicy = Arc<dyn Fn(&str) -> Option<Limit> + Send + Sync>;
const UNLIMITED: Decision = Decision {
ok: true,
retry_after: None,
quota: None,
};
pub struct D1RateLimiter {
db: WorkerD1,
policy: RateLimitPolicy,
}
impl D1RateLimiter {
#[must_use]
pub fn new(db: WorkerD1, policy: RateLimitPolicy) -> Self {
Self { db, policy }
}
}
fn period_ms(period: Duration) -> i64 {
i64::try_from(period.as_millis()).unwrap_or(i64::MAX).max(1)
}
fn window_start(now_ms: i64, period: Duration) -> i64 {
let period = period_ms(period);
now_ms - now_ms.rem_euclid(period)
}
fn decide(limit: Limit, now_ms: i64, window: i64, count: i64) -> Decision {
let left_ms = (window + period_ms(limit.period) - now_ms).max(0);
let reset = Duration::from_millis(u64::try_from(left_ms).unwrap_or(u64::MAX));
let remaining = i64::from(limit.max)
.saturating_sub(count)
.clamp(0, i64::from(u32::MAX));
let quota = Quota {
limit: limit.max,
remaining: u32::try_from(remaining).unwrap_or(0),
reset,
};
if count <= i64::from(limit.max) {
Decision {
ok: true,
retry_after: None,
quota: Some(quota),
}
} else {
Decision {
ok: false,
retry_after: Some(reset),
quota: Some(Quota {
remaining: 0,
..quota
}),
}
}
}
fn transport(err: &worker::Error) -> RateLimitError {
RateLimitError::Transport(err.to_string())
}
#[async_trait]
impl RateLimiter for D1RateLimiter {
async fn limit(&self, key: &str) -> Result<Decision, RateLimitError> {
let Some(limit) = (self.policy)(key) else {
return Ok(UNLIMITED);
};
let now = time::OffsetDateTime::now_utc();
let now_ms = now.unix_timestamp() * 1_000 + i64::from(now.millisecond());
let window = window_start(now_ms, limit.period);
let json = [
serde_json::Value::String(key.to_owned()),
serde_json::Value::from(window),
];
let values: Vec<worker::wasm_bindgen::JsValue> = json
.iter()
.map(worker::d1::serde_wasm_bindgen::to_value)
.collect::<Result<Vec<_>, _>>()
.map_err(|err| transport(&worker::Error::SerdeWasmBindgenError(err)))?;
let prepared = self
.db
.prepare(UPSERT_SQL)
.bind(&values)
.map_err(|err| transport(&err))?;
let results = self
.db
.batch(vec![prepared])
.into_send()
.await
.map_err(|err| transport(&err))?;
let row: serde_json::Value = results
.first()
.and_then(|result| result.results().ok())
.and_then(|rows| rows.into_iter().next())
.ok_or_else(|| {
RateLimitError::Transport("rate-limit upsert returned no row".to_owned())
})?;
let count = row
.get("count")
.and_then(serde_json::Value::as_i64)
.unwrap_or(1);
let returned_window = row
.get("window_start")
.and_then(serde_json::Value::as_i64)
.unwrap_or(window);
Ok(decide(limit, now_ms, returned_window, count))
}
}
#[cfg(test)]
mod tests {
use super::*;
const MINUTE: Duration = Duration::from_secs(60);
#[test]
fn windows_align_to_whole_periods_since_the_epoch() {
assert_eq!(
window_start(12 * 60 * 1_000 + 1_500, MINUTE),
12 * 60 * 1_000
);
assert_eq!(window_start(0, MINUTE), 0);
}
#[test]
fn a_degenerate_period_divides_by_one_millisecond() {
assert_eq!(window_start(1_500, Duration::ZERO), 1_500);
}
#[test]
fn the_nth_request_passes_and_the_next_waits_out_the_window() {
let limit = Limit {
max: 3,
period: MINUTE,
};
let (now, window) = (90_000, 60_000);
let third = decide(limit, now, window, 3);
assert!(third.ok);
assert_eq!(third.retry_after, None);
let quota = third.quota.expect("quota");
assert_eq!((quota.limit, quota.remaining), (3, 0));
assert_eq!(quota.reset, Duration::from_secs(30));
let fourth = decide(limit, now, window, 4);
assert!(!fourth.ok);
assert_eq!(fourth.retry_after, Some(Duration::from_secs(30)));
let quota = fourth.quota.expect("quota");
assert_eq!((quota.limit, quota.remaining), (3, 0));
assert_eq!(quota.reset, Duration::from_secs(30));
}
#[test]
fn remaining_counts_down_from_the_limit() {
let limit = Limit {
max: 5,
period: MINUTE,
};
let decision = decide(limit, 0, 0, 2);
assert!(decision.ok);
assert_eq!(decision.quota.expect("quota").remaining, 3);
}
#[test]
fn a_count_beyond_the_cap_clamps_to_zero_remaining() {
let limit = Limit {
max: 5,
period: MINUTE,
};
let decision = decide(limit, 1_000, 0, 500);
assert!(!decision.ok);
assert_eq!(decision.quota.expect("quota").remaining, 0);
}
}