Skip to main content

proxima_primitives/pipe/
rate_limit.rs

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// ── HTTP key extractor ────────────────────────────────────────────────────────
36
37#[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/// Caps on the per-key bucket map.
81#[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
98/// Token-bucket rate limiter. Generic over the inner handle, the extractor
99/// type, AND the clock — mirrors `resilience::Retry<Inner, Clk>`'s injected-clock
100/// seam so refill timing is deterministically testable. `Clk` defaults to
101/// [`TimeClock`], the production monotonic clock, so every existing caller
102/// (`RateLimit::new`, `RateLimitConfig`, `RateLimitFactory`) is unaffected.
103///
104/// Sans-IO: the limiter owns no runtime and spawns no task. Map growth is bounded
105/// by `caps.max_keys` (LRU eviction on insert, inline on the call path). Proactive
106/// reclamation of idle buckets is the host's job — call [`sweep_idle`] on whatever
107/// cadence fits (read `caps.sweep_period` for the recommended interval) from a
108/// `proxima_core::time::interval` loop on your own runtime. That keeps the primitive
109/// runtime-agnostic (prime, tokio, embassy, bare-metal — it neither knows nor
110/// cares which drives the host's tick).
111///
112/// [`sweep_idle`]: RateLimit::sweep_idle
113pub 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
123// Hardcoded to `TimeClock` (not `Clk: Default`) so `RateLimit::new(...)` and
124// `RateLimit::with_caps(...)` resolve without turbofish, the same way
125// `HashMap::new()` pins its defaulted hasher param — a bare `Clk: Default`
126// bound here would leave the type parameter ambiguous at every existing call
127// site with no annotation to pin it.
128impl<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    /// Materialise with an explicit clock — the seam a deterministic test or
147    /// example injects a fake clock through; production code goes via `new`
148    /// / `with_caps`, which default `Clk` to [`TimeClock`].
149    #[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    // only called from `mod tests` below, which is itself `not(loom)`-gated.
172    #[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    /// Evict buckets idle longer than `caps.idle_ttl`. Sans-IO: the host calls
186    /// this on its own cadence (the limiter spawns nothing). Bounded growth does
187    /// not depend on it — `caps.max_keys` already caps the map via LRU eviction
188    /// on insert; this just reclaims idle memory sooner.
189    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        // proactively cap before a possible insert; the table also self-defends
199        // on a full wrap, but evicting here keeps `count <= max_keys`. when the
200        // key already exists get_or_insert returns it unchanged, so an eviction
201        // here only ever sheds a colder distinct key.
202        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/// Serialisable key-extraction strategy — the config mirror of [`KeyExtractor`].
216/// The `path_and_method` default matches the historical hand-parser.
217#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
218#[serde(tag = "key", rename_all = "snake_case")]
219pub enum KeyConfig {
220    /// One shared bucket keyed by a constant string.
221    Constant {
222        #[serde(default = "default_constant_value", rename = "constant_value")]
223        value: String,
224    },
225    /// Per-value bucket keyed by a request header.
226    Header { header_name: String },
227    /// Per-(method, path) bucket — the default.
228    #[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/// Serialisable caps on the per-key bucket map (durations in ms). Mirrors
247/// [`RateLimitCaps`] with the same defaults.
248#[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/// Typed config surface for the `rate_limit` middleware (token bucket).
295#[derive(Debug, Clone, Builder, Deserialize, Serialize, Settings)]
296#[settings(prefix = "PROXIMA_RATE_LIMIT")]
297#[builder(derive(Clone, Debug))]
298pub struct RateLimitConfig {
299    /// Token bucket capacity (burst). Required.
300    #[setting(default)]
301    #[serde(default)]
302    #[builder(default)]
303    pub capacity: u64,
304
305    /// Steady-state refill rate in tokens per second. Required.
306    #[setting(default)]
307    #[serde(default)]
308    #[builder(default)]
309    pub refill_per_sec: u64,
310
311    /// Key-extraction strategy. Flattened so `key = "header"` + `header_name`
312    /// sit at the top level, matching the historical spec.
313    #[setting(skip)]
314    #[serde(default, flatten)]
315    #[builder(default)]
316    pub key: KeyConfig,
317
318    /// Per-key bucket-map caps.
319    #[setting(skip)]
320    #[serde(default)]
321    #[builder(default)]
322    pub caps: CapsConfig,
323
324    /// `Retry-After` (ms) advertised on a 429. `None` keeps the limiter default.
325    #[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    /// Materialise the limiter around `inner`, on the production [`TimeClock`].
355    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    /// `now_nanos` comes from the injected [`Clock`] — the bucket itself holds
560    /// no clock and reads no real time, so its refill math is deterministic
561    /// under a fake clock.
562    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    // `now_nanos` is read once per call by the caller (the injected Clock),
580    // not re-read on each CAS retry below — a CAS spin is contention on this
581    // same bucket, not a new logical "check", so one timestamp covers it.
582    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// `#[proxima::test]` pulls in the `proxima` dev-dependency, which the
615// loom build keeps out of the graph (see
616// `[target.'cfg(not(loom))'.dev-dependencies]` in Cargo.toml); these
617// tests are unrelated to the Notify/watch loom protocol.
618#[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    // principle-4 parity: the fluent builder and the config value must lower to
688    // identical RateLimit state (token config, extractor, caps, exceeded action).
689    #[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        // a was the lru victim before c's insert; reinserting it makes a fresh
871        // bucket (distinct Arc), proving the original was evicted.
872        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        // injected clock -> deterministic, no sleep. fresh bucket touched "now",
882        // stale bucket last seen long ago; sweep at now with a 1s ttl drops only stale.
883        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    // ── Clock-injectable determinism ────────────────────────────────────────
1085    //
1086    // `RateLimit::with_clock` takes any `Clock` impl, so refill can be proven
1087    // deterministically — `advance` moves virtual time, never a real sleep.
1088    // `RecordingClock` is the canonical double for this (see
1089    // `crate::pipe::clock::testing` for why it, not `MockClock`).
1090
1091    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        // fill -> exhaust: capacity is 2, so two calls admit and a third refuses.
1108        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        // no clock advance yet: still exhausted at the same instant.
1118        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        // advance -> refill resumes: refill_per_sec=1 over 1s refills one token.
1126        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        // exhaust again at the same instant: that one refilled token is spent.
1132        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        // even a generous refill rate cannot admit a second call: the clock
1159        // never advanced, so try_take's elapsed_ns is exactly zero.
1160        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}