cratefield_runtime_cloudflare/ports/
d1_rate_limit.rs1use async_trait::async_trait;
9use std::sync::Arc;
10use std::time::Duration;
11
12use cratefield_core::{Decision, Quota, RateLimitError, RateLimiter};
13use worker::D1Database as WorkerD1;
14use worker::send::IntoSendFuture;
15
16pub const RATE_LIMIT_COUNTERS_SQL: &str = "CREATE TABLE IF NOT EXISTS rate_limit_counters (\
20key TEXT PRIMARY KEY, \
21window_start INTEGER NOT NULL, \
22count INTEGER NOT NULL);";
23
24const UPSERT_SQL: &str = "INSERT INTO rate_limit_counters \
26(key, window_start, count) VALUES (?1, ?2, 1) \
27ON CONFLICT(key) DO UPDATE SET \
28count = CASE WHEN window_start = excluded.window_start THEN count + 1 ELSE 1 END, \
29window_start = excluded.window_start \
30RETURNING count, window_start";
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub struct Limit {
36 pub max: u32,
39 pub period: Duration,
42}
43
44pub type RateLimitPolicy = Arc<dyn Fn(&str) -> Option<Limit> + Send + Sync>;
51
52const UNLIMITED: Decision = Decision {
54 ok: true,
55 retry_after: None,
56 quota: None,
57};
58
59pub struct D1RateLimiter {
60 db: WorkerD1,
61 policy: RateLimitPolicy,
62}
63
64impl D1RateLimiter {
65 #[must_use]
66 pub fn new(db: WorkerD1, policy: RateLimitPolicy) -> Self {
67 Self { db, policy }
68 }
69}
70
71fn period_ms(period: Duration) -> i64 {
75 i64::try_from(period.as_millis()).unwrap_or(i64::MAX).max(1)
76}
77
78fn window_start(now_ms: i64, period: Duration) -> i64 {
81 let period = period_ms(period);
82 now_ms - now_ms.rem_euclid(period)
83}
84
85fn decide(limit: Limit, now_ms: i64, window: i64, count: i64) -> Decision {
90 let left_ms = (window + period_ms(limit.period) - now_ms).max(0);
91 let reset = Duration::from_millis(u64::try_from(left_ms).unwrap_or(u64::MAX));
92 let remaining = i64::from(limit.max)
93 .saturating_sub(count)
94 .clamp(0, i64::from(u32::MAX));
95 let quota = Quota {
96 limit: limit.max,
97 remaining: u32::try_from(remaining).unwrap_or(0),
100 reset,
101 };
102 if count <= i64::from(limit.max) {
103 Decision {
104 ok: true,
105 retry_after: None,
106 quota: Some(quota),
107 }
108 } else {
109 Decision {
110 ok: false,
111 retry_after: Some(reset),
112 quota: Some(Quota {
113 remaining: 0,
114 ..quota
115 }),
116 }
117 }
118}
119
120fn transport(err: &worker::Error) -> RateLimitError {
121 RateLimitError::Transport(err.to_string())
122}
123
124#[async_trait]
125impl RateLimiter for D1RateLimiter {
126 async fn limit(&self, key: &str) -> Result<Decision, RateLimitError> {
127 let Some(limit) = (self.policy)(key) else {
128 return Ok(UNLIMITED);
129 };
130 let now = time::OffsetDateTime::now_utc();
131 let now_ms = now.unix_timestamp() * 1_000 + i64::from(now.millisecond());
132 let window = window_start(now_ms, limit.period);
133
134 let json = [
139 serde_json::Value::String(key.to_owned()),
140 serde_json::Value::from(window),
141 ];
142 let values: Vec<worker::wasm_bindgen::JsValue> = json
143 .iter()
144 .map(worker::d1::serde_wasm_bindgen::to_value)
145 .collect::<Result<Vec<_>, _>>()
146 .map_err(|err| transport(&worker::Error::SerdeWasmBindgenError(err)))?;
147 let prepared = self
148 .db
149 .prepare(UPSERT_SQL)
150 .bind(&values)
151 .map_err(|err| transport(&err))?;
152 let results = self
156 .db
157 .batch(vec![prepared])
158 .into_send()
159 .await
160 .map_err(|err| transport(&err))?;
161 let row: serde_json::Value = results
162 .first()
163 .and_then(|result| result.results().ok())
164 .and_then(|rows| rows.into_iter().next())
165 .ok_or_else(|| {
166 RateLimitError::Transport("rate-limit upsert returned no row".to_owned())
167 })?;
168 let count = row
169 .get("count")
170 .and_then(serde_json::Value::as_i64)
171 .unwrap_or(1);
172 let returned_window = row
173 .get("window_start")
174 .and_then(serde_json::Value::as_i64)
175 .unwrap_or(window);
176 Ok(decide(limit, now_ms, returned_window, count))
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 const MINUTE: Duration = Duration::from_secs(60);
185
186 #[test]
187 fn windows_align_to_whole_periods_since_the_epoch() {
188 assert_eq!(
190 window_start(12 * 60 * 1_000 + 1_500, MINUTE),
191 12 * 60 * 1_000
192 );
193 assert_eq!(window_start(0, MINUTE), 0);
194 }
195
196 #[test]
197 fn a_degenerate_period_divides_by_one_millisecond() {
198 assert_eq!(window_start(1_500, Duration::ZERO), 1_500);
199 }
200
201 #[test]
202 fn the_nth_request_passes_and_the_next_waits_out_the_window() {
203 let limit = Limit {
204 max: 3,
205 period: MINUTE,
206 };
207 let (now, window) = (90_000, 60_000);
208
209 let third = decide(limit, now, window, 3);
210 assert!(third.ok);
211 assert_eq!(third.retry_after, None);
212 let quota = third.quota.expect("quota");
213 assert_eq!((quota.limit, quota.remaining), (3, 0));
214 assert_eq!(quota.reset, Duration::from_secs(30));
215
216 let fourth = decide(limit, now, window, 4);
217 assert!(!fourth.ok);
218 assert_eq!(fourth.retry_after, Some(Duration::from_secs(30)));
219 let quota = fourth.quota.expect("quota");
220 assert_eq!((quota.limit, quota.remaining), (3, 0));
221 assert_eq!(quota.reset, Duration::from_secs(30));
222 }
223
224 #[test]
225 fn remaining_counts_down_from_the_limit() {
226 let limit = Limit {
227 max: 5,
228 period: MINUTE,
229 };
230 let decision = decide(limit, 0, 0, 2);
231 assert!(decision.ok);
232 assert_eq!(decision.quota.expect("quota").remaining, 3);
233 }
234
235 #[test]
236 fn a_count_beyond_the_cap_clamps_to_zero_remaining() {
237 let limit = Limit {
238 max: 5,
239 period: MINUTE,
240 };
241 let decision = decide(limit, 1_000, 0, 500);
242 assert!(!decision.ok);
243 assert_eq!(decision.quota.expect("quota").remaining, 0);
244 }
245}