1use bytes::Bytes;
2use std::borrow::Cow;
3use std::future::Future;
4use std::pin::Pin;
5use std::sync::Arc;
6use std::sync::atomic::{AtomicU64, Ordering};
7use std::time::Duration;
8
9use crate::pipe::SendPipe;
10use crate::pipe::capabilities::Clock;
11pub use crate::pipe::capabilities::{ExceededAction, KeyOf};
12use crate::pipe::primitives::Pipe;
13use bon::Builder;
14use conflaguration::{Settings, Validate, ValidationMessage};
15use serde::{Deserialize, Serialize};
16use serde_json::Value;
17
18use crate::pipe::bucket_table::BucketTable;
19use crate::pipe::clock::TimeClock;
20use crate::pipe::handler::{PipeHandle, ThreadLocalPipeHandle, into_handle};
21use crate::pipe::labeled::Labeled;
22use crate::pipe::pipe_factory::PipeFactory;
23use crate::pipe::request::{Request, Response};
24use crate::pipe::telemetry_surface::Labels;
25use proxima_core::ProximaError;
26
27const MICROS_PER_TOKEN: u64 = 1_000_000;
28const NANOS_PER_SEC: u128 = 1_000_000_000;
29
30const METRIC_REJECTED: &str = "proxima.rate_limit.rejected_total";
31const METRIC_ADMITTED: &str = "proxima.rate_limit.admitted_total";
32
33const MISSING_HEADER_KEY: &str = "__missing__";
34
35#[derive(Debug, Clone)]
38pub enum KeyExtractor {
39 ConstantKey(String),
40 Header(String),
41 PathAndMethod,
42}
43
44impl KeyOf<KeyExtractor> for Request<Bytes> {
45 type Rejection = Response<Bytes>;
46
47 fn rate_key<'a>(&'a self, extractor: &'a KeyExtractor) -> Cow<'a, [u8]> {
48 match extractor {
49 KeyExtractor::ConstantKey(value) => Cow::Borrowed(value.as_bytes()),
50 KeyExtractor::Header(name) => self
51 .metadata
52 .iter()
53 .find(|(header_name, _)| header_name.as_ref().eq_ignore_ascii_case(name.as_bytes()))
54 .map_or(
55 Cow::Borrowed(MISSING_HEADER_KEY.as_bytes()),
56 |(_, value)| Cow::Borrowed(value.as_ref()),
57 ),
58 KeyExtractor::PathAndMethod => {
59 let mut buffer =
60 Vec::with_capacity(self.method.as_bytes().len() + 1 + self.path.len());
61 buffer.extend_from_slice(self.method.as_bytes());
62 buffer.push(b' ');
63 buffer.extend_from_slice(&self.path);
64 Cow::Owned(buffer)
65 }
66 }
67 }
68
69 fn build_rejection(action: &ExceededAction) -> Response<Bytes> {
70 rejected_response(action)
71 }
72}
73
74#[derive(Debug, Clone, Copy)]
75pub struct TokenBucketConfig {
76 pub capacity: u64,
77 pub refill_per_sec: u64,
78}
79
80#[derive(Debug, Clone, Copy)]
82pub struct RateLimitCaps {
83 pub max_keys: usize,
84 pub idle_ttl: Duration,
85 pub sweep_period: Duration,
86}
87
88impl Default for RateLimitCaps {
89 fn default() -> Self {
90 Self {
91 max_keys: 100_000,
92 idle_ttl: Duration::from_secs(600),
93 sweep_period: Duration::from_secs(30),
94 }
95 }
96}
97
98pub struct RateLimit<Inner = PipeHandle, Extractor = KeyExtractor, Clk = TimeClock> {
114 inner: Inner,
115 config: TokenBucketConfig,
116 extractor: Extractor,
117 on_exceeded: ExceededAction,
118 buckets: BucketTable<AtomicBucket>,
119 caps: RateLimitCaps,
120 clock: Clk,
121}
122
123impl<Inner, Extractor> RateLimit<Inner, Extractor, TimeClock> {
129 #[must_use]
130 pub fn new(inner: Inner, config: TokenBucketConfig, extractor: Extractor) -> Self {
131 Self::with_caps(inner, config, extractor, RateLimitCaps::default())
132 }
133
134 #[must_use]
135 pub fn with_caps(
136 inner: Inner,
137 config: TokenBucketConfig,
138 extractor: Extractor,
139 caps: RateLimitCaps,
140 ) -> Self {
141 Self::with_clock(inner, config, extractor, caps, TimeClock)
142 }
143}
144
145impl<Inner, Extractor, Clk> RateLimit<Inner, Extractor, Clk> {
146 #[must_use]
150 pub fn with_clock(
151 inner: Inner,
152 config: TokenBucketConfig,
153 extractor: Extractor,
154 caps: RateLimitCaps,
155 clock: Clk,
156 ) -> Self {
157 let buckets: BucketTable<AtomicBucket> = BucketTable::with_max_keys(caps.max_keys);
158 Self {
159 inner,
160 config,
161 extractor,
162 on_exceeded: ExceededAction::Reject429 {
163 retry_after_ms: 1000,
164 },
165 buckets,
166 caps,
167 clock,
168 }
169 }
170
171 #[cfg(all(test, not(loom)))]
173 pub(crate) fn bucket_count(&self) -> usize {
174 self.buckets.len()
175 }
176
177 #[must_use]
178 pub fn with_action(mut self, action: ExceededAction) -> Self {
179 self.on_exceeded = action;
180 self
181 }
182}
183
184impl<Inner, Extractor, Clk: Clock> RateLimit<Inner, Extractor, Clk> {
185 pub fn sweep_idle(&self) {
190 sweep_idle_at(
191 &self.buckets,
192 self.clock.now_nanos() / 1_000,
193 self.caps.idle_ttl,
194 );
195 }
196
197 pub(crate) fn bucket_for(&self, key: &[u8]) -> Arc<AtomicBucket> {
198 if self.buckets.len() >= self.caps.max_keys {
203 evict_lru(&self.buckets);
204 }
205 let config = self.config;
206 let now_nanos = self.clock.now_nanos();
207 let bucket = self
208 .buckets
209 .get_or_insert(key, || AtomicBucket::new(config, now_nanos));
210 bucket.touch(now_nanos);
211 bucket
212 }
213}
214
215#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(tag = "key", rename_all = "snake_case")]
219pub enum KeyConfig {
220 Constant {
222 #[serde(default = "default_constant_value", rename = "constant_value")]
223 value: String,
224 },
225 Header { header_name: String },
227 #[default]
229 PathAndMethod,
230}
231
232fn default_constant_value() -> String {
233 "__global__".to_string()
234}
235
236impl From<KeyConfig> for KeyExtractor {
237 fn from(config: KeyConfig) -> Self {
238 match config {
239 KeyConfig::Constant { value } => KeyExtractor::ConstantKey(value),
240 KeyConfig::Header { header_name } => KeyExtractor::Header(header_name),
241 KeyConfig::PathAndMethod => KeyExtractor::PathAndMethod,
242 }
243 }
244}
245
246#[derive(Debug, Clone, Copy, Builder, Serialize, Deserialize)]
249#[builder(derive(Clone, Debug))]
250pub struct CapsConfig {
251 #[serde(default = "default_max_keys")]
252 #[builder(default = default_max_keys())]
253 pub max_keys: usize,
254 #[serde(default = "default_idle_ttl_ms")]
255 #[builder(default = default_idle_ttl_ms())]
256 pub idle_ttl_ms: u64,
257 #[serde(default = "default_sweep_ms")]
258 #[builder(default = default_sweep_ms())]
259 pub sweep_ms: u64,
260}
261
262fn default_max_keys() -> usize {
263 100_000
264}
265
266fn default_idle_ttl_ms() -> u64 {
267 600_000
268}
269
270fn default_sweep_ms() -> u64 {
271 30_000
272}
273
274impl Default for CapsConfig {
275 fn default() -> Self {
276 Self {
277 max_keys: default_max_keys(),
278 idle_ttl_ms: default_idle_ttl_ms(),
279 sweep_ms: default_sweep_ms(),
280 }
281 }
282}
283
284impl From<CapsConfig> for RateLimitCaps {
285 fn from(config: CapsConfig) -> Self {
286 Self {
287 max_keys: config.max_keys,
288 idle_ttl: Duration::from_millis(config.idle_ttl_ms),
289 sweep_period: Duration::from_millis(config.sweep_ms),
290 }
291 }
292}
293
294#[derive(Debug, Clone, Builder, Deserialize, Serialize, Settings)]
296#[settings(prefix = "PROXIMA_RATE_LIMIT")]
297#[builder(derive(Clone, Debug))]
298pub struct RateLimitConfig {
299 #[setting(default)]
301 #[serde(default)]
302 #[builder(default)]
303 pub capacity: u64,
304
305 #[setting(default)]
307 #[serde(default)]
308 #[builder(default)]
309 pub refill_per_sec: u64,
310
311 #[setting(skip)]
314 #[serde(default, flatten)]
315 #[builder(default)]
316 pub key: KeyConfig,
317
318 #[setting(skip)]
320 #[serde(default)]
321 #[builder(default)]
322 pub caps: CapsConfig,
323
324 #[setting(default)]
326 #[serde(default)]
327 pub retry_after_ms: Option<u64>,
328}
329
330impl Validate for RateLimitConfig {
331 fn validate(&self) -> conflaguration::Result<()> {
332 let mut errors = Vec::new();
333 if self.capacity == 0 {
334 errors.push(ValidationMessage::new(
335 "capacity",
336 "rate_limit.capacity required",
337 ));
338 }
339 if self.refill_per_sec == 0 {
340 errors.push(ValidationMessage::new(
341 "refill_per_sec",
342 "rate_limit.refill_per_sec required",
343 ));
344 }
345 if errors.is_empty() {
346 Ok(())
347 } else {
348 Err(conflaguration::Error::Validation { errors })
349 }
350 }
351}
352
353impl RateLimitConfig {
354 pub fn from_config(
356 self,
357 inner: PipeHandle,
358 ) -> Result<RateLimit<PipeHandle, KeyExtractor, TimeClock>, ProximaError> {
359 self.validate()
360 .map_err(|err| ProximaError::Config(format!("{err}")))?;
361 let mut limiter = RateLimit::with_caps(
362 inner,
363 TokenBucketConfig {
364 capacity: self.capacity,
365 refill_per_sec: self.refill_per_sec,
366 },
367 self.key.into(),
368 self.caps.into(),
369 );
370 if let Some(retry_after) = self.retry_after_ms {
371 limiter.on_exceeded = ExceededAction::Reject429 {
372 retry_after_ms: retry_after,
373 };
374 }
375 Ok(limiter)
376 }
377}
378
379impl RateLimit<PipeHandle, KeyExtractor, TimeClock> {
380 pub fn from_spec(inner: PipeHandle, value: &Value) -> Result<Self, ProximaError> {
381 let config: RateLimitConfig = serde_json::from_value(value.clone())
382 .map_err(|err| ProximaError::Config(format!("rate_limit config: {err}")))?;
383 config.from_config(inner)
384 }
385}
386
387fn evict_lru(buckets: &BucketTable<AtomicBucket>) {
388 buckets.evict_one_lru(|bucket| bucket.last_access_micros.load(Ordering::Relaxed));
389}
390
391fn sweep_idle_at(buckets: &BucketTable<AtomicBucket>, now_micros: u64, idle_ttl: Duration) {
392 let ttl_micros = idle_ttl.as_micros() as u64;
393 buckets.sweep_idle(now_micros, ttl_micros, |bucket| {
394 bucket.last_access_micros.load(Ordering::Relaxed)
395 });
396}
397
398async fn run_rate_limit<In, Out, Err, Key, Call, Fut>(
399 args: RunArgs<In, Key>,
400 call_inner: Call,
401) -> Result<Out, Err>
402where
403 In: KeyOf<Key, Rejection = Out> + Labeled,
404 Key: Send + Sync,
405 Call: Fn(In) -> Fut,
406 Fut: Future<Output = Result<Out, Err>>,
407{
408 let RunArgs {
409 extractor,
410 input,
411 bucket,
412 action,
413 now_nanos,
414 } = args;
415 let telemetry = input.telemetry();
416 let base_labels = input.labels();
417 let key = input.rate_key(&extractor);
418 let admitted = bucket.try_take(MICROS_PER_TOKEN, now_nanos);
419 let label_key_str = std::str::from_utf8(key.as_ref()).unwrap_or("");
420 let labels = with_extra(&base_labels, "key", label_key_str);
421 if admitted {
422 telemetry.counter_inc(METRIC_ADMITTED, &labels, 1);
423 call_inner(input).await
424 } else {
425 telemetry.counter_inc(METRIC_REJECTED, &labels, 1);
426 Ok(In::build_rejection(&action))
427 }
428}
429
430struct RunArgs<In, Key> {
431 extractor: Key,
432 input: In,
433 bucket: Arc<AtomicBucket>,
434 action: ExceededAction,
435 now_nanos: u64,
436}
437
438impl<Inner, Extractor, Clk> SendPipe for RateLimit<Inner, Extractor, Clk>
439where
440 Inner: SendPipe + Clone + Send + Sync + 'static,
441 Inner::In: KeyOf<Extractor, Rejection = Inner::Out> + Labeled + Send + 'static,
442 Inner::Out: Send + 'static,
443 Inner::Err: Send + 'static,
444 Extractor: Clone + Send + Sync + 'static,
445 Clk: Clock + Send + Sync + 'static,
446{
447 type In = Inner::In;
448 type Out = Inner::Out;
449 type Err = Inner::Err;
450
451 fn call(
452 &self,
453 input: Inner::In,
454 ) -> impl Future<Output = Result<Inner::Out, Inner::Err>> + Send {
455 let bucket = self.bucket_for(input.rate_key(&self.extractor).as_ref());
456 let now_nanos = self.clock.now_nanos();
457 let inner = self.inner.clone();
458 run_rate_limit(
459 RunArgs {
460 extractor: self.extractor.clone(),
461 input,
462 bucket,
463 action: self.on_exceeded,
464 now_nanos,
465 },
466 move |admitted| {
467 let inner = inner.clone();
468 async move { SendPipe::call(&inner, admitted).await }
469 },
470 )
471 }
472}
473
474impl<Extractor, Clk> Pipe for RateLimit<ThreadLocalPipeHandle, Extractor, Clk>
475where
476 Request<Bytes>: KeyOf<Extractor, Rejection = Response<Bytes>>,
477 Extractor: Clone + Send + Sync + 'static,
478 Clk: Clock + Send + Sync + 'static,
479{
480 type In = Request<Bytes>;
481 type Out = Response<Bytes>;
482 type Err = ProximaError;
483
484 fn call(
485 &self,
486 input: Request<Bytes>,
487 ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> {
488 let bucket = self.bucket_for(input.rate_key(&self.extractor).as_ref());
489 let now_nanos = self.clock.now_nanos();
490 let inner = self.inner.clone();
491 run_rate_limit(
492 RunArgs {
493 extractor: self.extractor.clone(),
494 input,
495 bucket,
496 action: self.on_exceeded,
497 now_nanos,
498 },
499 move |admitted| {
500 let inner = inner.clone();
501 async move { Pipe::call(&inner, admitted).await }
502 },
503 )
504 }
505}
506
507fn rejected_response(action: &ExceededAction) -> Response<Bytes> {
508 match action {
509 ExceededAction::Reject429 { retry_after_ms } => {
510 let retry_after_seconds = retry_after_ms.div_ceil(1000).max(1);
511 Response::new(429)
512 .with_header("retry-after", retry_after_seconds.to_string())
513 .with_body("rate limit exceeded")
514 }
515 }
516}
517
518fn with_extra(base: &Labels, key: &str, value: &str) -> Labels {
519 let mut pairs: Vec<(String, String)> = base.entries().to_vec();
520 pairs.push((key.to_string(), value.to_string()));
521 let pair_refs: Vec<(&str, &str)> = pairs
522 .iter()
523 .map(|(name, value)| (name.as_str(), value.as_str()))
524 .collect();
525 Labels::from_pairs(&pair_refs)
526}
527
528pub struct RateLimitFactory;
529
530impl PipeFactory for RateLimitFactory {
531 fn name(&self) -> &str {
532 "rate_limit"
533 }
534
535 fn build(
536 &self,
537 spec: &Value,
538 inner: Option<PipeHandle>,
539 ) -> Pin<Box<dyn Future<Output = Result<PipeHandle, ProximaError>> + Send + '_>> {
540 let spec = spec.clone();
541 Box::pin(async move {
542 let inner = inner
543 .ok_or_else(|| ProximaError::Config("rate_limit requires an inner pipe".into()))?;
544 let limiter = RateLimit::from_spec(inner, &spec)?;
545 Ok(into_handle(limiter))
546 })
547 }
548}
549
550pub(crate) struct AtomicBucket {
551 tokens_micro: AtomicU64,
552 last_refill_ns: AtomicU64,
553 pub(crate) last_access_micros: AtomicU64,
554 capacity_micro: u64,
555 refill_per_sec_micro: u64,
556}
557
558impl AtomicBucket {
559 fn new(config: TokenBucketConfig, now_nanos: u64) -> Self {
563 let capacity_micro = config.capacity.saturating_mul(MICROS_PER_TOKEN);
564 let refill_per_sec_micro = config.refill_per_sec.saturating_mul(MICROS_PER_TOKEN);
565 Self {
566 tokens_micro: AtomicU64::new(capacity_micro),
567 last_refill_ns: AtomicU64::new(now_nanos),
568 last_access_micros: AtomicU64::new(now_nanos / 1_000),
569 capacity_micro,
570 refill_per_sec_micro,
571 }
572 }
573
574 fn touch(&self, now_nanos: u64) {
575 self.last_access_micros
576 .store(now_nanos / 1_000, Ordering::Relaxed);
577 }
578
579 pub(crate) fn try_take(&self, cost_micro: u64, now_nanos: u64) -> bool {
583 loop {
584 let last_ns = self.last_refill_ns.load(Ordering::Acquire);
585 let current = self.tokens_micro.load(Ordering::Acquire);
586 let elapsed_ns = now_nanos.saturating_sub(last_ns);
587 let added_micro = (u128::from(elapsed_ns) * u128::from(self.refill_per_sec_micro)
588 / NANOS_PER_SEC) as u64;
589 let refilled = current.saturating_add(added_micro).min(self.capacity_micro);
590 if refilled < cost_micro {
591 if added_micro > 0
592 && self
593 .tokens_micro
594 .compare_exchange(current, refilled, Ordering::Release, Ordering::Acquire)
595 .is_ok()
596 {
597 self.last_refill_ns.store(now_nanos, Ordering::Release);
598 }
599 return false;
600 }
601 let after = refilled - cost_micro;
602 if self
603 .tokens_micro
604 .compare_exchange(current, after, Ordering::Release, Ordering::Acquire)
605 .is_ok()
606 {
607 self.last_refill_ns.store(now_nanos, Ordering::Release);
608 return true;
609 }
610 }
611 }
612}
613
614#[cfg(all(test, not(loom)))]
619#[allow(
620 clippy::unwrap_used,
621 clippy::expect_used,
622 clippy::field_reassign_with_default,
623 clippy::type_complexity,
624 clippy::useless_vec,
625 clippy::needless_range_loop,
626 clippy::default_constructed_unit_structs
627)]
628mod tests {
629 use std::collections::HashMap;
630 use std::future::Future;
631 use std::sync::Mutex;
632
633 use super::*;
634 use crate::pipe::handler::into_handle;
635 use crate::pipe::telemetry_surface::{Telemetry, TelemetryHandle};
636
637 #[derive(Default)]
638 struct Metrics {
639 counters: Mutex<HashMap<(String, Vec<(String, String)>), u64>>,
640 }
641
642 impl Telemetry for Metrics {
643 fn counter_inc(&self, metric: &str, labels: &Labels, by: u64) {
644 let key = (metric.to_string(), labels.entries().to_vec());
645 *self.counters.lock().unwrap().entry(key).or_insert(0) += by;
646 }
647
648 fn gauge_set(&self, _: &str, _: &Labels, _: i64) {}
649
650 fn histogram_record(&self, _: &str, _: &Labels, _: f64) {}
651 }
652
653 impl Metrics {
654 fn counter(&self, metric: &str, labels: &Labels) -> Option<u64> {
655 let key = (metric.to_string(), labels.entries().to_vec());
656 self.counters.lock().unwrap().get(&key).copied()
657 }
658 }
659
660 struct AlwaysOk;
661
662 impl SendPipe for AlwaysOk {
663 type In = Request<Bytes>;
664 type Out = Response<Bytes>;
665 type Err = ProximaError;
666
667 fn call(
668 &self,
669 _request: Request<Bytes>,
670 ) -> impl Future<Output = Result<Response<Bytes>, ProximaError>> + Send {
671 async { Ok(Response::ok("ok")) }
672 }
673 }
674
675 fn build_request_with_metrics(method: &str, path: &str) -> (Request<Bytes>, Arc<Metrics>) {
676 let metrics: Arc<Metrics> = Arc::new(Metrics::default());
677 let telemetry: TelemetryHandle = metrics.clone();
678 let request = Request::builder()
679 .method(method)
680 .path(path)
681 .telemetry(telemetry)
682 .build()
683 .expect("builder");
684 (request, metrics)
685 }
686
687 #[test]
690 fn parity_fluent_builder_and_config_value_match() {
691 let from_value: RateLimitConfig = serde_json::from_value(serde_json::json!({
692 "capacity": 10,
693 "refill_per_sec": 5,
694 "key": "header",
695 "header_name": "x-api-key",
696 "caps": {"max_keys": 1000, "idle_ttl_ms": 60000, "sweep_ms": 5000},
697 "retry_after_ms": 2000,
698 }))
699 .expect("from_value");
700 let from_value = from_value
701 .from_config(into_handle(AlwaysOk))
702 .expect("from_config value");
703
704 let from_builder = RateLimitConfig::builder()
705 .capacity(10)
706 .refill_per_sec(5)
707 .key(KeyConfig::Header {
708 header_name: "x-api-key".to_string(),
709 })
710 .caps(
711 CapsConfig::builder()
712 .max_keys(1000)
713 .idle_ttl_ms(60000)
714 .sweep_ms(5000)
715 .build(),
716 )
717 .retry_after_ms(2000)
718 .build()
719 .from_config(into_handle(AlwaysOk))
720 .expect("from_config builder");
721
722 assert_eq!(from_value.config.capacity, from_builder.config.capacity);
723 assert_eq!(
724 from_value.config.refill_per_sec,
725 from_builder.config.refill_per_sec
726 );
727 assert_eq!(from_value.caps.max_keys, from_builder.caps.max_keys);
728 assert_eq!(from_value.caps.idle_ttl, from_builder.caps.idle_ttl);
729 assert_eq!(from_value.caps.sweep_period, from_builder.caps.sweep_period);
730 assert_eq!(
731 format!("{:?}", from_value.extractor),
732 format!("{:?}", from_builder.extractor)
733 );
734 assert_eq!(
735 format!("{:?}", from_value.on_exceeded),
736 format!("{:?}", from_builder.on_exceeded)
737 );
738 }
739
740 #[proxima::test]
741 async fn token_bucket_admits_until_empty_then_rejects() {
742 let stack = RateLimit::new(
743 into_handle(AlwaysOk),
744 TokenBucketConfig {
745 capacity: 2,
746 refill_per_sec: 0,
747 },
748 KeyExtractor::ConstantKey("global".into()),
749 );
750
751 for _ in 0..2 {
752 let (request, _metrics) = build_request_with_metrics("GET", "/");
753 let response = SendPipe::call(&stack, request).await.expect("call");
754 assert_eq!(response.status, 200);
755 }
756 let (request, _metrics) = build_request_with_metrics("GET", "/");
757 let response = SendPipe::call(&stack, request).await.expect("call");
758 assert_eq!(response.status, 429);
759 assert_eq!(
760 response.metadata.get_str("retry-after"),
761 Some("1"),
762 "retry-after must be present and round up to seconds",
763 );
764 }
765
766 #[proxima::test]
767 async fn distinct_keys_have_independent_buckets() {
768 let stack = RateLimit::new(
769 into_handle(AlwaysOk),
770 TokenBucketConfig {
771 capacity: 1,
772 refill_per_sec: 0,
773 },
774 KeyExtractor::PathAndMethod,
775 );
776
777 let (request_a, _metrics_a) = build_request_with_metrics("GET", "/a");
778 let (request_b, _metrics_b) = build_request_with_metrics("GET", "/b");
779 assert_eq!(
780 SendPipe::call(&stack, request_a).await.expect("a").status,
781 200
782 );
783 assert_eq!(
784 SendPipe::call(&stack, request_b).await.expect("b").status,
785 200
786 );
787
788 let (request_a_again, _) = build_request_with_metrics("GET", "/a");
789 assert_eq!(
790 SendPipe::call(&stack, request_a_again)
791 .await
792 .expect("a again")
793 .status,
794 429
795 );
796 }
797
798 #[proxima::test]
799 async fn reject_increments_telemetry_counter() {
800 let stack = RateLimit::new(
801 into_handle(AlwaysOk),
802 TokenBucketConfig {
803 capacity: 1,
804 refill_per_sec: 0,
805 },
806 KeyExtractor::ConstantKey("global".into()),
807 );
808
809 let metrics: Arc<Metrics> = Arc::new(Metrics::default());
810 let telemetry: TelemetryHandle = metrics.clone();
811 let one = Request::builder()
812 .method("GET")
813 .path("/")
814 .telemetry(telemetry.clone())
815 .build()
816 .expect("builder one");
817 let two = Request::builder()
818 .method("GET")
819 .path("/")
820 .telemetry(telemetry)
821 .build()
822 .expect("builder two");
823
824 let _ = SendPipe::call(&stack, one).await.expect("first admitted");
825 let _ = SendPipe::call(&stack, two).await.expect("second rejected");
826
827 let labels = Labels::from_pairs(&[("key", "global")]);
828 let rejected = metrics.counter(METRIC_REJECTED, &labels);
829 assert_eq!(rejected, Some(1));
830 }
831
832 #[proxima::test]
833 async fn max_keys_cap_evicts_lru_on_insert() {
834 let limiter = RateLimit::with_caps(
835 into_handle(AlwaysOk),
836 TokenBucketConfig {
837 capacity: 1,
838 refill_per_sec: 0,
839 },
840 KeyExtractor::Header("x-tenant".into()),
841 RateLimitCaps {
842 max_keys: 2,
843 idle_ttl: Duration::from_secs(600),
844 sweep_period: Duration::from_secs(60),
845 },
846 );
847
848 let mut original_a: Option<Arc<AtomicBucket>> = None;
849 for tenant in ["a", "b", "c"] {
850 let metrics: Arc<Metrics> = Arc::new(Metrics::default());
851 let telemetry: TelemetryHandle = metrics.clone();
852 let request = Request::builder()
853 .method("GET")
854 .path("/")
855 .header("x-tenant", tenant)
856 .telemetry(telemetry)
857 .build()
858 .expect("builder");
859 let bucket = limiter.bucket_for(tenant.as_bytes());
860 if tenant == "a" {
861 original_a = Some(bucket);
862 }
863 std::hint::black_box(request);
864 }
865 assert_eq!(
866 limiter.bucket_count(),
867 2,
868 "max_keys cap must evict on insert"
869 );
870 let reinserted_a = limiter.bucket_for(b"a");
873 assert!(
874 !Arc::ptr_eq(&reinserted_a, original_a.as_ref().expect("a inserted")),
875 "a was evicted; reinsert yields a fresh bucket"
876 );
877 }
878
879 #[test]
880 fn sweep_idle_at_evicts_only_buckets_past_ttl() {
881 let buckets: BucketTable<AtomicBucket> = BucketTable::with_max_keys(8);
884 let config = TokenBucketConfig {
885 capacity: 1,
886 refill_per_sec: 1,
887 };
888 let now_micros = 10_000_000;
889 let idle_ttl = Duration::from_secs(1);
890
891 let fresh = buckets.get_or_insert(b"fresh", || AtomicBucket::new(config, 0));
892 fresh
893 .last_access_micros
894 .store(now_micros, Ordering::Relaxed);
895
896 let stale = buckets.get_or_insert(b"stale", || AtomicBucket::new(config, 0));
897 stale
898 .last_access_micros
899 .store(now_micros - 5_000_000, Ordering::Relaxed);
900
901 sweep_idle_at(&buckets, now_micros, idle_ttl);
902
903 assert_eq!(
904 buckets.len(),
905 1,
906 "only the stale bucket past ttl is evicted"
907 );
908 let fresh_again = buckets.get_or_insert(b"fresh", || AtomicBucket::new(config, 0));
909 assert!(
910 Arc::ptr_eq(&fresh_again, &fresh),
911 "fresh bucket survives unchanged"
912 );
913 let stale_again = buckets.get_or_insert(b"stale", || AtomicBucket::new(config, 0));
914 assert!(
915 !Arc::ptr_eq(&stale_again, &stale),
916 "stale was evicted, fresh on reinsert"
917 );
918 }
919
920 #[test]
921 fn key_of_constant_returns_borrowed_slice() {
922 let extractor = KeyExtractor::ConstantKey("tenant-42".into());
923 let request = Request::builder()
924 .method("GET")
925 .path("/")
926 .build()
927 .expect("builder");
928 match request.rate_key(&extractor) {
929 Cow::Borrowed(value) => assert_eq!(value, b"tenant-42"),
930 Cow::Owned(_) => panic!("ConstantKey must not allocate"),
931 }
932 }
933
934 #[test]
935 fn key_of_header_borrows_from_request_value() {
936 let extractor = KeyExtractor::Header("x-api-key".into());
937 let request = Request::builder()
938 .method("GET")
939 .path("/")
940 .header("X-API-Key", "abc123")
941 .build()
942 .expect("builder");
943 match request.rate_key(&extractor) {
944 Cow::Borrowed(value) => assert_eq!(value, b"abc123"),
945 Cow::Owned(_) => panic!("Header extractor must borrow from the request"),
946 }
947 }
948
949 #[test]
950 fn key_of_path_and_method_owns_the_synthesized_key() {
951 let extractor = KeyExtractor::PathAndMethod;
952 let request = Request::builder()
953 .method("GET")
954 .path("/v1/items/42")
955 .build()
956 .expect("builder");
957 match request.rate_key(&extractor) {
958 Cow::Borrowed(_) => panic!("PathAndMethod must synthesize a new String"),
959 Cow::Owned(value) => assert_eq!(value, b"GET /v1/items/42"),
960 }
961 }
962
963 #[test]
964 fn parse_caps_uses_defaults_when_block_absent() {
965 let caps: RateLimitCaps = CapsConfig::default().into();
966 assert_eq!(caps.max_keys, 100_000);
967 assert_eq!(caps.idle_ttl, Duration::from_secs(600));
968 }
969
970 #[test]
971 fn parse_caps_overrides_individual_fields() {
972 let value = serde_json::json!({"max_keys": 16, "idle_ttl_ms": 5000, "sweep_ms": 1000});
973 let config: CapsConfig = serde_json::from_value(value).expect("parse");
974 let caps: RateLimitCaps = config.into();
975 assert_eq!(caps.max_keys, 16);
976 assert_eq!(caps.idle_ttl, Duration::from_millis(5000));
977 assert_eq!(caps.sweep_period, Duration::from_millis(1000));
978 }
979
980 #[derive(Clone, PartialEq, Debug)]
981 struct TenantEvent {
982 tenant: String,
983 value: u64,
984 }
985
986 #[derive(Clone)]
987 struct TenantExtractor;
988
989 impl KeyOf<TenantExtractor> for TenantEvent {
990 type Rejection = TenantEvent;
991
992 fn rate_key<'a>(&'a self, _extractor: &'a TenantExtractor) -> Cow<'a, [u8]> {
993 Cow::Borrowed(self.tenant.as_bytes())
994 }
995
996 fn build_rejection(_action: &ExceededAction) -> TenantEvent {
997 TenantEvent {
998 tenant: "__rate_limited__".into(),
999 value: 0,
1000 }
1001 }
1002 }
1003
1004 impl Labeled for TenantEvent {
1005 fn telemetry(&self) -> TelemetryHandle {
1006 Arc::new(Metrics::default())
1007 }
1008
1009 fn labels(&self) -> Labels {
1010 Labels::empty()
1011 }
1012 }
1013
1014 #[derive(Clone)]
1015 struct EventPassthrough;
1016
1017 impl SendPipe for EventPassthrough {
1018 type In = TenantEvent;
1019 type Out = TenantEvent;
1020 type Err = ProximaError;
1021
1022 fn call(
1023 &self,
1024 input: TenantEvent,
1025 ) -> impl Future<Output = Result<TenantEvent, ProximaError>> + Send {
1026 async move {
1027 Ok(TenantEvent {
1028 value: input.value + 1,
1029 ..input
1030 })
1031 }
1032 }
1033 }
1034
1035 #[proxima::test]
1036 async fn rate_limit_is_generic_over_a_non_request_key_type() {
1037 let stack = RateLimit::new(
1038 EventPassthrough,
1039 TokenBucketConfig {
1040 capacity: 1,
1041 refill_per_sec: 0,
1042 },
1043 TenantExtractor,
1044 );
1045
1046 let admitted = SendPipe::call(
1047 &stack,
1048 TenantEvent {
1049 tenant: "acme".into(),
1050 value: 10,
1051 },
1052 )
1053 .await
1054 .expect("first event admitted");
1055 assert_eq!(admitted.value, 11, "passthrough increments value");
1056
1057 let limited = SendPipe::call(
1058 &stack,
1059 TenantEvent {
1060 tenant: "acme".into(),
1061 value: 10,
1062 },
1063 )
1064 .await
1065 .expect("second event returns Ok with sentinel");
1066 assert_eq!(
1067 limited.tenant, "__rate_limited__",
1068 "exhausted bucket returns build_rejection sentinel"
1069 );
1070 assert_eq!(limited.value, 0);
1071
1072 let other = SendPipe::call(
1073 &stack,
1074 TenantEvent {
1075 tenant: "beta".into(),
1076 value: 5,
1077 },
1078 )
1079 .await
1080 .expect("different tenant has its own bucket");
1081 assert_eq!(other.value, 6, "beta bucket is independent");
1082 }
1083
1084 use crate::pipe::clock::testing::RecordingClock;
1092
1093 #[proxima::test]
1094 async fn fake_clock_drives_fill_exhaust_advance_refill_exhaust() {
1095 let clock = RecordingClock::new();
1096 let stack = RateLimit::with_clock(
1097 into_handle(AlwaysOk),
1098 TokenBucketConfig {
1099 capacity: 2,
1100 refill_per_sec: 1,
1101 },
1102 KeyExtractor::ConstantKey("global".into()),
1103 RateLimitCaps::default(),
1104 clock.clone(),
1105 );
1106
1107 for attempt in 0..2 {
1109 let (request, _metrics) = build_request_with_metrics("GET", "/");
1110 let response = SendPipe::call(&stack, request).await.expect("call");
1111 assert_eq!(response.status, 200, "attempt {attempt} within capacity");
1112 }
1113 let (request, _metrics) = build_request_with_metrics("GET", "/");
1114 let exhausted = SendPipe::call(&stack, request).await.expect("call");
1115 assert_eq!(exhausted.status, 429, "bucket exhausted, no time elapsed");
1116
1117 let (request, _metrics) = build_request_with_metrics("GET", "/");
1119 let still_exhausted = SendPipe::call(&stack, request).await.expect("call");
1120 assert_eq!(
1121 still_exhausted.status, 429,
1122 "no wall-clock time passed, no fake-clock advance either"
1123 );
1124
1125 clock.advance(Duration::from_secs(1));
1127 let (request, _metrics) = build_request_with_metrics("GET", "/");
1128 let resumed = SendPipe::call(&stack, request).await.expect("call");
1129 assert_eq!(resumed.status, 200, "1s advance refills exactly one token");
1130
1131 let (request, _metrics) = build_request_with_metrics("GET", "/");
1133 let exhausted_again = SendPipe::call(&stack, request).await.expect("call");
1134 assert_eq!(
1135 exhausted_again.status, 429,
1136 "the refilled token was just spent; no further clock advance"
1137 );
1138 }
1139
1140 #[proxima::test]
1141 async fn fake_clock_never_refills_when_no_time_advances() {
1142 let clock = RecordingClock::new();
1143 let stack = RateLimit::with_clock(
1144 into_handle(AlwaysOk),
1145 TokenBucketConfig {
1146 capacity: 1,
1147 refill_per_sec: 1_000_000,
1148 },
1149 KeyExtractor::ConstantKey("global".into()),
1150 RateLimitCaps::default(),
1151 clock,
1152 );
1153
1154 let (first, _metrics) = build_request_with_metrics("GET", "/");
1155 let admitted = SendPipe::call(&stack, first).await.expect("call");
1156 assert_eq!(admitted.status, 200);
1157
1158 for _ in 0..5 {
1161 let (request, _metrics) = build_request_with_metrics("GET", "/");
1162 let response = SendPipe::call(&stack, request).await.expect("call");
1163 assert_eq!(
1164 response.status, 429,
1165 "no clock advance means no refill, regardless of call count"
1166 );
1167 }
1168 }
1169}