Skip to main content

relay_quotas/
redis.rs

1use std::fmt::{self, Debug, Write};
2use std::sync::Arc;
3
4use itertools::Itertools;
5use relay_base_schema::metrics::MetricNamespace;
6use relay_base_schema::organization::OrganizationId;
7use relay_common::time::UnixTimestamp;
8use relay_log::protocol::value;
9use relay_redis::redis::{self, FromRedisValue, ParsingError, Script};
10use relay_redis::{AsyncRedisClient, RedisError, RedisScripts};
11use thiserror::Error;
12
13use crate::cache::OpportunisticQuotaCache;
14use crate::quota::{ItemScoping, Quota, QuotaScope};
15use crate::rate_limit::{RateLimit, RateLimits, RetryAfter};
16use crate::statsd::{QuotaCounters, QuotaTimers};
17use crate::{REJECT_ALL_SECS, cache};
18
19/// The `grace` period allows accommodating for clock drift in TTL
20/// calculation since the clock on the Redis instance used to store quota
21/// metrics may not be in sync with the computer running this code.
22const GRACE: u64 = 60;
23
24/// An error returned by [`RedisRateLimiter`].
25#[derive(Debug, Error)]
26#[error("failed to communicate with redis")]
27pub struct RateLimitingError(
28    #[from]
29    #[source]
30    pub RedisError,
31);
32
33/// Creates a refund key for a given counter key.
34///
35/// Refund keys are used to track credits that should be applied to a quota,
36/// allowing for more flexible quota management.
37fn get_refunded_quota_key(counter_key: &str) -> String {
38    format!("r:{counter_key}")
39}
40
41/// A transparent wrapper around an Option that only displays `Some`.
42struct OptionalDisplay<T>(Option<T>);
43
44impl<T> fmt::Display for OptionalDisplay<T>
45where
46    T: fmt::Display,
47{
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        match self.0 {
50            Some(ref value) => write!(f, "{value}"),
51            None => Ok(()),
52        }
53    }
54}
55
56/// Owned version of [`RedisQuota`].
57#[derive(Debug, Clone)]
58pub struct OwnedRedisQuota {
59    /// The original quota.
60    quota: Quota,
61    /// Scopes of the item being tracked.
62    scoping: ItemScoping,
63    /// The Redis key prefix mapped from the quota id.
64    prefix: Arc<str>,
65    /// The Redis window in seconds mapped from the quota.
66    window: u64,
67    /// The quantity being checked.
68    quantity: u64,
69    /// The ingestion timestamp determining the rate limiting bucket.
70    timestamp: UnixTimestamp,
71}
72
73impl OwnedRedisQuota {
74    /// Returns an instance of [`RedisQuota`] which borrows from this [`OwnedRedisQuota`].
75    pub fn build_ref(&self) -> RedisQuota<'_> {
76        RedisQuota {
77            quota: &self.quota,
78            scoping: &self.scoping,
79            prefix: Arc::clone(&self.prefix),
80            window: self.window,
81            quantity: self.quantity,
82            timestamp: self.timestamp,
83        }
84    }
85}
86
87/// Reference to information required for tracking quotas in Redis.
88#[derive(Debug, Clone, Eq, PartialEq)]
89pub struct RedisQuota<'a> {
90    /// The original quota.
91    quota: &'a Quota,
92    /// Scopes of the item being tracked.
93    scoping: &'a ItemScoping,
94    /// The Redis key prefix mapped from the quota id.
95    prefix: Arc<str>,
96    /// The Redis window in seconds mapped from the quota.
97    window: u64,
98    /// The quantity being checked.
99    quantity: u64,
100    /// The ingestion timestamp determining the rate limiting bucket.
101    timestamp: UnixTimestamp,
102}
103
104impl<'a> RedisQuota<'a> {
105    /// Creates a new [`RedisQuota`] from a [`Quota`], item scoping, and timestamp.
106    ///
107    /// Returns `None` if the quota cannot be tracked in Redis because it's missing
108    /// required fields (ID or window). This allows forward compatibility with
109    /// future quota types.
110    pub fn new(
111        quota: &'a Quota,
112        quantity: u64,
113        scoping: &'a ItemScoping,
114        timestamp: UnixTimestamp,
115    ) -> Option<Self> {
116        // These fields indicate that we *can* track this quota.
117        let prefix = quota.id.clone()?;
118        let window = quota.window?;
119
120        Some(Self {
121            quota,
122            scoping,
123            prefix,
124            quantity,
125            window,
126            timestamp,
127        })
128    }
129
130    /// Converts this [`RedisQuota`] to an [`OwnedRedisQuota`] leaving the original
131    /// struct in place.
132    pub fn build_owned(&self) -> OwnedRedisQuota {
133        OwnedRedisQuota {
134            quota: self.quota.clone(),
135            scoping: self.scoping.clone(),
136            prefix: Arc::clone(&self.prefix),
137            window: self.window,
138            quantity: self.quantity,
139            timestamp: self.timestamp,
140        }
141    }
142
143    /// Returns the window size of the quota in seconds.
144    pub fn window(&self) -> u64 {
145        self.window
146    }
147
148    /// Returns the prefix of the quota used for Redis key generation.
149    pub fn prefix(&self) -> &str {
150        &self.prefix
151    }
152
153    /// Returns the quantity to rate limit.
154    pub fn quantity(&self) -> u64 {
155        self.quantity
156    }
157
158    /// Returns the limit value formatted for Redis.
159    ///
160    /// Returns `-1` for unlimited quotas or when the limit doesn't fit into an `i64`.
161    /// Otherwise, returns the limit value as an `i64`.
162    pub fn limit(&self) -> i64 {
163        self.limit
164            // If it does not fit into i64, treat as unlimited:
165            .and_then(|limit| limit.try_into().ok())
166            .unwrap_or(-1)
167    }
168
169    fn shift(&self) -> u64 {
170        self.scoping.organization_id.value() % self.window
171    }
172
173    /// Returns the current time slot of the quota based on the timestamp.
174    ///
175    /// Slots are used to determine the time bucket for rate limiting.
176    pub fn slot(&self) -> u64 {
177        (self.timestamp.as_secs() - self.shift()) / self.window
178    }
179
180    /// Returns the timestamp when the current quota window will expire.
181    pub fn expiry(&self) -> UnixTimestamp {
182        let next_slot = self.slot() + 1;
183        let next_start = next_slot * self.window + self.shift();
184        UnixTimestamp::from_secs(next_start)
185    }
186
187    /// Returns when the Redis key should expire.
188    ///
189    /// This is the expiry time plus a grace period.
190    pub fn key_expiry(&self) -> u64 {
191        self.expiry().as_secs() + GRACE
192    }
193
194    /// Returns the Redis key for this quota.
195    ///
196    /// The key includes the quota ID, organization ID, and other scoping information
197    /// based on the quota's scope type. Keys are structured to ensure proper isolation
198    /// between different organizations and scopes.
199    pub fn key(&self) -> QuotaCacheKey {
200        // The subscope id is only formatted into the key if the quota is not organization-scoped.
201        // The organization id is always included.
202        let subscope = match self.quota.scope {
203            QuotaScope::Organization => None,
204            scope => self.scoping.scope_id(scope),
205        };
206
207        QuotaCacheKey {
208            id: Arc::clone(&self.prefix),
209            org: self.scoping.organization_id,
210            subscope,
211            namespace: self.namespace,
212            slot: self.slot(),
213            dimensions: self.scoping.dimensions.to_string(self.quota),
214        }
215    }
216
217    /// Returns the maximum cardinality of the quota dimensions that we will support. This is
218    /// necessary to ensure we don't end up with huge numbers of buckets for some set of dimensions.
219    /// If we don't have any dimensions, than all values will hash to the same bucket, so we can
220    /// just say the cardinality is 1 in that case.
221    pub fn max_dimensions_cardinality(&self) -> u32 {
222        if let Some(group_by) = &self.group_by {
223            group_by.max_cardinality
224        } else {
225            1
226        }
227    }
228
229    /// Returns a [`cache::Quota`] built from this [`RedisQuota`].
230    fn for_cache(&self) -> cache::Quota<QuotaCacheKey> {
231        cache::Quota {
232            limit: self.limit(),
233            window: self.window,
234            key: self.key(),
235            expiry: UnixTimestamp::from_secs(self.key_expiry()),
236        }
237    }
238}
239
240impl std::ops::Deref for RedisQuota<'_> {
241    type Target = Quota;
242
243    fn deref(&self) -> &Self::Target {
244        self.quota
245    }
246}
247
248/// A key which uniquely identifies a quota.
249///
250/// Can be used as a Redis cache key by using the [`fmt::Display`] trait.
251///
252/// See also: [`RedisQuota::key`].
253#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
254pub struct QuotaCacheKey {
255    id: Arc<str>,
256    org: OrganizationId,
257    subscope: Option<u64>,
258    namespace: Option<MetricNamespace>,
259    slot: u64,
260    dimensions: Option<String>,
261}
262
263struct RedisQuotaKey {
264    redis_key: String,
265    hash_key: Option<String>,
266}
267
268impl QuotaCacheKey {
269    fn to_redis_quota_key(&self) -> RedisQuotaKey {
270        let mut redis_key = String::new();
271
272        write!(
273            &mut redis_key,
274            "quota:{id}{{{org}}}{subscope}{namespace}:{slot}",
275            id = self.id,
276            org = self.org,
277            subscope = OptionalDisplay(self.subscope),
278            namespace = OptionalDisplay(self.namespace),
279            slot = self.slot,
280        )
281        .expect("should be infallible");
282
283        RedisQuotaKey {
284            redis_key,
285            hash_key: self.dimensions.clone(),
286        }
287    }
288}
289
290/// A service that executes quotas and checks for rate limits in a shared cache.
291///
292/// Quotas handle tracking a project's usage and respond whether a project has been
293/// configured to throttle incoming data if they go beyond the specified quota.
294///
295/// Quotas can specify a window to be tracked in, such as per minute or per hour. Additionally,
296/// quotas allow to specify the data categories they apply to, for example error events or
297/// attachments. For more information on quota parameters, see [`Quota`].
298///
299/// Requires the `redis` feature.
300#[derive(Clone)]
301pub struct RedisRateLimiter {
302    client: AsyncRedisClient,
303    cache: Option<Arc<OpportunisticQuotaCache<QuotaCacheKey>>>,
304    script: &'static Script,
305    max_limit: Option<u64>,
306}
307
308impl RedisRateLimiter {
309    /// Creates a new [`RedisRateLimiter`] instance.
310    pub fn new(client: AsyncRedisClient) -> Self {
311        RedisRateLimiter {
312            client,
313            cache: None,
314            script: RedisScripts::load_is_rate_limited(),
315            max_limit: None,
316        }
317    }
318
319    /// Sets the maximum rate limit in seconds.
320    ///
321    /// By default, this rate limiter will return rate limits based on the quotas' `window` fields.
322    /// If a maximum rate limit is set, the returned rate limit will be bounded by this value.
323    pub fn max_limit(mut self, max_limit: Option<u64>) -> Self {
324        self.max_limit = max_limit;
325        self
326    }
327
328    /// Enables an opportunistic cache for quotas.
329    ///
330    /// The opportunistic cache, opportunistically serves quotas from a local cache, reducing the
331    /// load on Redis heavily.
332    ///
333    /// Caching considers a ratio of the remaining quota to be available and periodically
334    /// synchronizes with Redis.
335    pub fn cache(mut self, cache_ratio: Option<f32>, max: Option<f32>) -> Self {
336        self.cache = cache_ratio
337            .map(OpportunisticQuotaCache::new)
338            .map(|c| c.with_max(max))
339            .map(Arc::new);
340
341        self
342    }
343
344    /// Checks whether any of the quotas in effect have been exceeded and records consumption.
345    ///
346    /// By invoking this method, the caller signals that data is being ingested and needs to be
347    /// counted against the quota. This increment happens atomically if none of the quotas have been
348    /// exceeded. Otherwise, a rate limit is returned and data is not counted against the quotas.
349    ///
350    /// If no key is specified, then only organization-wide and project-wide quotas are checked. If
351    /// a key is specified, then key-quotas are also checked.
352    ///
353    /// When `over_accept_once` is set to `true` and the current quota would be exceeded by the
354    /// provided `quantity`, the data is accepted once and subsequent requests will be rejected
355    /// until the quota refreshes.
356    ///
357    /// A `quantity` of `0` can be used to check if the quota limit has been reached or exceeded
358    /// without incrementing it in the success case. This is useful for checking quotas in a different
359    /// data category.
360    pub async fn is_rate_limited<'a>(
361        &self,
362        quotas: impl IntoIterator<Item = &'a Quota>,
363        item_scoping: &ItemScoping,
364        quantity: usize,
365        over_accept_once: bool,
366    ) -> Result<RateLimits, RateLimitingError> {
367        let timestamp = UnixTimestamp::now();
368        let mut invocation = self.script.prepare_invoke();
369        let mut tracked_quotas = Vec::new();
370        let mut rate_limits = RateLimits::new();
371
372        let quantity = u64::try_from(quantity).unwrap_or(u64::MAX);
373
374        for quota in quotas {
375            if !quota.matches(item_scoping) {
376                // Silently skip all quotas that do not apply to this item.
377            } else if quota.limit == Some(0) {
378                // A zero-sized quota is strongest. Do not call into Redis at all, and do not
379                // increment any keys, as one quota has reached capacity (this is how regular quotas
380                // behave as well).
381                let retry_after = self.retry_after(REJECT_ALL_SECS);
382                rate_limits.add(RateLimit::from_quota(quota, item_scoping, retry_after));
383            } else if let Some(mut quota) =
384                RedisQuota::new(quota, quantity, item_scoping, timestamp)
385            {
386                if let Some(cache) = &self.cache {
387                    quota.quantity = match cache.check_quota(quota.for_cache(), quantity) {
388                        cache::Action::Accept => continue,
389                        cache::Action::Check(quantity) => quantity,
390                    };
391                }
392
393                let redis_quota_key = quota.key().to_redis_quota_key();
394                let mut redis_key = redis_quota_key.redis_key;
395                // Remaining quotas are expected to be track-able in Redis.
396                let refund_key = get_refunded_quota_key(&redis_key);
397
398                let redis_dims_key = redis_quota_key.hash_key;
399
400                if redis_dims_key.is_some() {
401                    redis_key += ":hash";
402                }
403
404                let redis_dims_key = redis_dims_key.as_deref().unwrap_or(crate::EMPTY_DIMENSIONS);
405
406                let max_cardinality = quota.max_dimensions_cardinality();
407
408                invocation.key(redis_key);
409                invocation.key(refund_key);
410
411                invocation.arg(quota.limit());
412                invocation.arg(quota.key_expiry());
413                invocation.arg(quota.quantity);
414                invocation.arg(over_accept_once);
415                invocation.arg(redis_dims_key);
416                invocation.arg(max_cardinality);
417
418                tracked_quotas.push(quota);
419            } else {
420                // This quota is neither a static reject-all, nor can it be tracked in Redis due to
421                // missing fields. We're skipping this for forward-compatibility.
422                relay_log::with_scope(
423                    |scope| scope.set_extra("quota", value::to_value(quota).unwrap()),
424                    || relay_log::warn!("skipping unsupported quota"),
425                )
426            }
427        }
428
429        // Either there are no quotas to run against Redis, or we already have a rate limit from a
430        // zero-sized quota. In either cases, skip invoking the script and return early.
431        if tracked_quotas.is_empty() || rate_limits.is_limited() {
432            return Ok(rate_limits);
433        }
434
435        let mut connection = self.client.get_connection().await?;
436        let result: ScriptResult = invocation
437            .invoke_async(&mut connection)
438            .await
439            .map_err(RedisError::Redis)?;
440
441        for (quota, state) in tracked_quotas.iter().zip(result.0) {
442            if state.is_rejected {
443                // We can calculate the error by comparing how much the cache added to the
444                // quantity with remaining difference of the consumption and limit.
445                let cache_error = {
446                    let remaining = quota.limit().saturating_sub(state.consumed).max(0) as u64;
447                    let cache_quantity = quota.quantity.saturating_sub(quantity);
448
449                    cache_quantity.saturating_sub(remaining)
450                };
451                relay_statsd::metric!(
452                    counter(QuotaCounters::CacheError) += cache_error,
453                    category = item_scoping.category.name(),
454                );
455
456                let retry_after = self.retry_after((quota.expiry() - timestamp).as_secs());
457                rate_limits.add(RateLimit::from_quota(quota, item_scoping, retry_after));
458            } else if let Some(cache) = &self.cache {
459                // Only update the cache if it's really necessary. Quotas which are being rejected,
460                // will not be able to be handled from the cache anyways.
461                cache.set_quota(quota.for_cache(), state.consumed);
462            }
463        }
464        drop(connection);
465
466        if let Some(cache) = &self.cache {
467            let vacuum_start = std::time::Instant::now();
468            if cache.try_vacuum(timestamp) {
469                relay_statsd::metric!(
470                    timer(QuotaTimers::CacheVacuumDuration) = vacuum_start.elapsed()
471                );
472            }
473        }
474
475        Ok(rate_limits)
476    }
477
478    /// Creates a [`RetryAfter`] value that is bounded by the configured [`max_limit`](Self::max_limit).
479    ///
480    /// If a maximum rate limit has been set, the returned value will not exceed that limit.
481    fn retry_after(&self, mut seconds: u64) -> RetryAfter {
482        if let Some(max_limit) = self.max_limit {
483            seconds = std::cmp::min(seconds, max_limit);
484        }
485
486        RetryAfter::from_secs(seconds)
487    }
488}
489
490/// The result returned from the rate limiting Redis script.
491#[derive(Debug)]
492struct ScriptResult(Vec<QuotaState>);
493
494impl FromRedisValue for ScriptResult {
495    fn from_redis_value(v: redis::Value) -> Result<Self, ParsingError> {
496        let seq = v.into_sequence().map_err(|v| {
497            format!("Expected a sequence from the rate limiting script (value was: {v:?})")
498        })?;
499
500        if !seq.len().is_multiple_of(2) {
501            return Err(format!(
502                "Expected an even number of values from the rate limiting script (value was: {seq:?})"
503            ).into());
504        }
505
506        let mut quotas = Vec::with_capacity(seq.len() / 2);
507        for (is_rejected, consumed) in seq.into_iter().tuples() {
508            quotas.push(QuotaState {
509                is_rejected: bool::from_redis_value(is_rejected)?,
510                consumed: i64::from_redis_value(consumed)?,
511            });
512        }
513
514        Ok(Self(quotas))
515    }
516}
517
518/// The state returned from the rate limiting script for a single quota.
519#[derive(Debug)]
520struct QuotaState {
521    /// Whether the quota rejects the request.
522    is_rejected: bool,
523    /// How much of the quota has already been consumed.
524    consumed: i64,
525}
526
527#[cfg(test)]
528mod tests {
529    use std::collections::BTreeSet;
530    use std::time::{SystemTime, UNIX_EPOCH};
531
532    use super::*;
533    use crate::quota::{DataCategories, DataCategory, ReasonCode, Scoping};
534    use crate::rate_limit::RateLimitScope;
535    use crate::{Dimension, DimensionMap, EMPTY_DIMENSIONS, GroupBy, MetricNamespaceScoping};
536    use relay_base_schema::metrics::MetricNamespace;
537    use relay_base_schema::organization::OrganizationId;
538    use relay_base_schema::project::{ProjectId, ProjectKey};
539    use relay_redis::RedisConfigOptions;
540    use relay_redis::redis::AsyncCommands;
541    use smallvec::smallvec;
542
543    fn build_rate_limiter() -> RedisRateLimiter {
544        let url = std::env::var("RELAY_REDIS_URL")
545            .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_owned());
546        let client =
547            AsyncRedisClient::single("test", &url, &RedisConfigOptions::default()).unwrap();
548
549        RedisRateLimiter {
550            client,
551            cache: None,
552            script: RedisScripts::load_is_rate_limited(),
553            max_limit: None,
554        }
555    }
556
557    #[tokio::test]
558    async fn test_zero_size_quotas() {
559        let quotas = &[
560            Quota {
561                id: None,
562                categories: DataCategories::new(),
563                scope: QuotaScope::Organization,
564                scope_id: None,
565                limit: Some(0),
566                window: None,
567                reason_code: Some(ReasonCode::new("get_lost")),
568                namespace: None,
569                group_by: None,
570            },
571            Quota {
572                id: Some("42".into()),
573                categories: DataCategories::new(),
574                scope: QuotaScope::Organization,
575                scope_id: None,
576                limit: None,
577                window: Some(42),
578                reason_code: Some(ReasonCode::new("unlimited")),
579                namespace: None,
580                group_by: None,
581            },
582        ];
583
584        let scoping = ItemScoping {
585            category: DataCategory::Error,
586            scoping: Scoping {
587                organization_id: OrganizationId::new(42),
588                project_id: ProjectId::new(43),
589                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
590                key_id: Some(44),
591            },
592            namespace: MetricNamespaceScoping::None,
593            dimensions: DimensionMap::default(),
594        };
595
596        let rate_limits: Vec<RateLimit> = build_rate_limiter()
597            .is_rate_limited(quotas, &scoping, 1, false)
598            .await
599            .expect("rate limiting failed")
600            .into_iter()
601            .collect();
602
603        assert_eq!(
604            rate_limits,
605            vec![RateLimit {
606                categories: DataCategories::new(),
607                scope: RateLimitScope::Organization(OrganizationId::new(42)),
608                reason_code: Some(ReasonCode::new("get_lost")),
609                retry_after: rate_limits[0].retry_after,
610                namespaces: smallvec![],
611                dimensional: false,
612            }]
613        );
614    }
615
616    /// Tests that a quota with and without namespace are counted separately.
617    #[tokio::test]
618    async fn test_namespace_quota() {
619        let quota_limit = 5;
620        let get_quota = |namespace: Option<MetricNamespace>| -> Quota {
621            Quota {
622                id: Some(format!("test_simple_quota_{}", uuid::Uuid::new_v4()).into()),
623                categories: DataCategories::new(),
624                scope: QuotaScope::Organization,
625                scope_id: None,
626                limit: Some(quota_limit),
627                window: Some(600),
628                reason_code: Some(ReasonCode::new(format!("ns: {namespace:?}"))),
629                namespace,
630                group_by: None,
631            }
632        };
633
634        let quotas = &[get_quota(None)];
635        let quota_with_namespace = &[get_quota(Some(MetricNamespace::Sessions))];
636
637        let scoping = ItemScoping {
638            category: DataCategory::Error,
639            scoping: Scoping {
640                organization_id: OrganizationId::new(42),
641                project_id: ProjectId::new(43),
642                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
643                key_id: Some(44),
644            },
645            namespace: MetricNamespaceScoping::Some(MetricNamespace::Sessions),
646            dimensions: DimensionMap::default(),
647        };
648
649        let rate_limiter = build_rate_limiter();
650
651        // First confirm normal behaviour without namespace.
652        for i in 0..10 {
653            let rate_limits: Vec<RateLimit> = rate_limiter
654                .is_rate_limited(quotas, &scoping, 1, false)
655                .await
656                .expect("rate limiting failed")
657                .into_iter()
658                .collect();
659
660            if i < quota_limit {
661                assert_eq!(rate_limits, vec![]);
662            } else {
663                assert_eq!(
664                    rate_limits[0].reason_code,
665                    Some(ReasonCode::new("ns: None"))
666                );
667            }
668        }
669
670        // Then, send identical quota with namespace and confirm it counts separately.
671        for i in 0..10 {
672            let rate_limits: Vec<RateLimit> = rate_limiter
673                .is_rate_limited(quota_with_namespace, &scoping, 1, false)
674                .await
675                .expect("rate limiting failed")
676                .into_iter()
677                .collect();
678
679            if i < quota_limit {
680                assert_eq!(rate_limits, vec![]);
681            } else {
682                assert_eq!(
683                    rate_limits[0].reason_code,
684                    Some(ReasonCode::new("ns: Some(Sessions)"))
685                );
686            }
687        }
688    }
689
690    #[tokio::test]
691    async fn test_simple_quota() {
692        let quotas = &[Quota {
693            id: Some(format!("test_simple_quota_{}", uuid::Uuid::new_v4()).into()),
694            categories: DataCategories::new(),
695            scope: QuotaScope::Organization,
696            scope_id: None,
697            limit: Some(5),
698            window: Some(60),
699            reason_code: Some(ReasonCode::new("get_lost")),
700            namespace: None,
701            group_by: None,
702        }];
703
704        let scoping = ItemScoping {
705            category: DataCategory::Error,
706            scoping: Scoping {
707                organization_id: OrganizationId::new(42),
708                project_id: ProjectId::new(43),
709                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
710                key_id: Some(44),
711            },
712            namespace: MetricNamespaceScoping::None,
713            dimensions: DimensionMap::default(),
714        };
715
716        let rate_limiter = build_rate_limiter();
717
718        for i in 0..10 {
719            let rate_limits: Vec<RateLimit> = rate_limiter
720                .is_rate_limited(quotas, &scoping, 1, false)
721                .await
722                .expect("rate limiting failed")
723                .into_iter()
724                .collect();
725
726            if i >= 5 {
727                assert_eq!(
728                    rate_limits,
729                    vec![RateLimit {
730                        categories: DataCategories::new(),
731                        scope: RateLimitScope::Organization(OrganizationId::new(42)),
732                        reason_code: Some(ReasonCode::new("get_lost")),
733                        retry_after: rate_limits[0].retry_after,
734                        namespaces: smallvec![],
735                        dimensional: false,
736                    }]
737                );
738            } else {
739                assert_eq!(rate_limits, vec![]);
740            }
741        }
742    }
743
744    #[tokio::test]
745    async fn test_quantity_0() {
746        let quotas = &[Quota {
747            id: Some(format!("test_quantity_0_{}", uuid::Uuid::new_v4()).into()),
748            categories: DataCategories::new(),
749            scope: QuotaScope::Organization,
750            scope_id: None,
751            limit: Some(1),
752            window: Some(60),
753            reason_code: Some(ReasonCode::new("get_lost")),
754            namespace: None,
755            group_by: None,
756        }];
757
758        let scoping = ItemScoping {
759            category: DataCategory::Error,
760            scoping: Scoping {
761                organization_id: OrganizationId::new(42),
762                project_id: ProjectId::new(43),
763                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
764                key_id: Some(44),
765            },
766            namespace: MetricNamespaceScoping::None,
767            dimensions: DimensionMap::default(),
768        };
769
770        let rate_limiter = build_rate_limiter();
771
772        // limit is 1, so first call not rate limited
773        assert!(
774            !rate_limiter
775                .is_rate_limited(quotas, &scoping, 1, false)
776                .await
777                .unwrap()
778                .is_limited()
779        );
780
781        // quota is now exhausted
782        assert!(
783            rate_limiter
784                .is_rate_limited(quotas, &scoping, 1, false)
785                .await
786                .unwrap()
787                .is_limited()
788        );
789
790        // quota is exhausted, regardless of the quantity
791        assert!(
792            rate_limiter
793                .is_rate_limited(quotas, &scoping, 0, false)
794                .await
795                .unwrap()
796                .is_limited()
797        );
798
799        // quota is exhausted, regardless of the quantity
800        assert!(
801            rate_limiter
802                .is_rate_limited(quotas, &scoping, 1, false)
803                .await
804                .unwrap()
805                .is_limited()
806        );
807    }
808
809    #[tokio::test]
810    async fn test_quota_go_over() {
811        let quotas = &[Quota {
812            id: Some(format!("test_quota_go_over{}", uuid::Uuid::new_v4()).into()),
813            categories: DataCategories::new(),
814            scope: QuotaScope::Organization,
815            scope_id: None,
816            limit: Some(2),
817            window: Some(60),
818            reason_code: Some(ReasonCode::new("get_lost")),
819            namespace: None,
820            group_by: None,
821        }];
822
823        let scoping = ItemScoping {
824            category: DataCategory::Error,
825            scoping: Scoping {
826                organization_id: OrganizationId::new(42),
827                project_id: ProjectId::new(43),
828                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
829                key_id: Some(44),
830            },
831            namespace: MetricNamespaceScoping::None,
832            dimensions: DimensionMap::default(),
833        };
834
835        let rate_limiter = build_rate_limiter();
836
837        // limit is 2, so first call not rate limited
838        let is_limited = rate_limiter
839            .is_rate_limited(quotas, &scoping, 1, true)
840            .await
841            .unwrap()
842            .is_limited();
843        assert!(!is_limited);
844
845        // go over limit, but first call is over-accepted
846        let is_limited = rate_limiter
847            .is_rate_limited(quotas, &scoping, 2, true)
848            .await
849            .unwrap()
850            .is_limited();
851        assert!(!is_limited);
852
853        // quota is exhausted, regardless of the quantity
854        let is_limited = rate_limiter
855            .is_rate_limited(quotas, &scoping, 0, true)
856            .await
857            .unwrap()
858            .is_limited();
859        assert!(is_limited);
860
861        // quota is exhausted, regardless of the quantity
862        let is_limited = rate_limiter
863            .is_rate_limited(quotas, &scoping, 1, true)
864            .await
865            .unwrap()
866            .is_limited();
867        assert!(is_limited);
868    }
869
870    #[tokio::test]
871    async fn test_bails_immediately_without_any_quota() {
872        let scoping = ItemScoping {
873            category: DataCategory::Error,
874            scoping: Scoping {
875                organization_id: OrganizationId::new(42),
876                project_id: ProjectId::new(43),
877                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
878                key_id: Some(44),
879            },
880            namespace: MetricNamespaceScoping::None,
881            dimensions: DimensionMap::default(),
882        };
883
884        let rate_limits: Vec<RateLimit> = build_rate_limiter()
885            .is_rate_limited(&[], &scoping, 1, false)
886            .await
887            .expect("rate limiting failed")
888            .into_iter()
889            .collect();
890
891        assert_eq!(rate_limits, vec![]);
892    }
893
894    #[tokio::test]
895    async fn test_limited_with_unlimited_quota() {
896        let quotas = &[
897            Quota {
898                id: Some("q0".into()),
899                categories: DataCategories::new(),
900                scope: QuotaScope::Organization,
901                scope_id: None,
902                limit: None,
903                window: Some(1),
904                reason_code: Some(ReasonCode::new("project_quota0")),
905                namespace: None,
906                group_by: None,
907            },
908            Quota {
909                id: Some("q1".into()),
910                categories: DataCategories::new(),
911                scope: QuotaScope::Organization,
912                scope_id: None,
913                limit: Some(1),
914                window: Some(1),
915                reason_code: Some(ReasonCode::new("project_quota1")),
916                namespace: None,
917                group_by: None,
918            },
919        ];
920
921        let scoping = ItemScoping {
922            category: DataCategory::Error,
923            scoping: Scoping {
924                organization_id: OrganizationId::new(42),
925                project_id: ProjectId::new(43),
926                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
927                key_id: Some(44),
928            },
929            namespace: MetricNamespaceScoping::None,
930            dimensions: DimensionMap::default(),
931        };
932
933        let rate_limiter = build_rate_limiter();
934
935        for i in 0..1 {
936            let rate_limits: Vec<RateLimit> = rate_limiter
937                .is_rate_limited(quotas, &scoping, 1, false)
938                .await
939                .expect("rate limiting failed")
940                .into_iter()
941                .collect();
942
943            if i == 0 {
944                assert_eq!(rate_limits, &[]);
945            } else {
946                assert_eq!(
947                    rate_limits,
948                    vec![RateLimit {
949                        categories: DataCategories::new(),
950                        scope: RateLimitScope::Organization(OrganizationId::new(42)),
951                        reason_code: Some(ReasonCode::new("project_quota1")),
952                        retry_after: rate_limits[0].retry_after,
953                        namespaces: smallvec![],
954                        dimensional: false,
955                    }]
956                );
957            }
958        }
959    }
960
961    #[tokio::test]
962    async fn test_quota_with_quantity() {
963        let quotas = &[Quota {
964            id: Some(format!("test_quantity_quota_{}", uuid::Uuid::new_v4()).into()),
965            categories: DataCategories::new(),
966            scope: QuotaScope::Organization,
967            scope_id: None,
968            limit: Some(500),
969            window: Some(60),
970            reason_code: Some(ReasonCode::new("get_lost")),
971            namespace: None,
972            group_by: None,
973        }];
974
975        let scoping = ItemScoping {
976            category: DataCategory::Error,
977            scoping: Scoping {
978                organization_id: OrganizationId::new(42),
979                project_id: ProjectId::new(43),
980                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
981                key_id: Some(44),
982            },
983            namespace: MetricNamespaceScoping::None,
984            dimensions: DimensionMap::default(),
985        };
986
987        let rate_limiter = build_rate_limiter();
988
989        for i in 0..10 {
990            let rate_limits: Vec<RateLimit> = rate_limiter
991                .is_rate_limited(quotas, &scoping, 100, false)
992                .await
993                .expect("rate limiting failed")
994                .into_iter()
995                .collect();
996
997            if i >= 5 {
998                assert_eq!(
999                    rate_limits,
1000                    vec![RateLimit {
1001                        categories: DataCategories::new(),
1002                        scope: RateLimitScope::Organization(OrganizationId::new(42)),
1003                        reason_code: Some(ReasonCode::new("get_lost")),
1004                        retry_after: rate_limits[0].retry_after,
1005                        namespaces: smallvec![],
1006                        dimensional: false,
1007                    }]
1008                );
1009            } else {
1010                assert_eq!(rate_limits, vec![]);
1011            }
1012        }
1013    }
1014
1015    #[tokio::test]
1016    async fn test_get_redis_key_scoped() {
1017        let quota = Quota {
1018            id: Some("foo".into()),
1019            categories: DataCategories::new(),
1020            scope: QuotaScope::Project,
1021            scope_id: Some("42".into()),
1022            window: Some(2),
1023            limit: Some(0),
1024            reason_code: None,
1025            namespace: None,
1026            group_by: None,
1027        };
1028
1029        let scoping = ItemScoping {
1030            category: DataCategory::Error,
1031            scoping: Scoping {
1032                organization_id: OrganizationId::new(69420),
1033                project_id: ProjectId::new(42),
1034                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1035                key_id: Some(4711),
1036            },
1037            namespace: MetricNamespaceScoping::None,
1038            dimensions: DimensionMap::default(),
1039        };
1040
1041        let timestamp = UnixTimestamp::from_secs(123_123_123);
1042        let redis_quota = RedisQuota::new(&quota, 0, &scoping, timestamp).unwrap();
1043        assert_eq!(
1044            redis_quota.key().to_redis_quota_key().redis_key,
1045            "quota:foo{69420}42:61561561"
1046        );
1047    }
1048
1049    #[tokio::test]
1050    async fn test_get_redis_key_unscoped() {
1051        let quota = Quota {
1052            id: Some("foo".into()),
1053            categories: DataCategories::new(),
1054            scope: QuotaScope::Organization,
1055            scope_id: None,
1056            window: Some(10),
1057            limit: Some(0),
1058            reason_code: None,
1059            namespace: None,
1060            group_by: None,
1061        };
1062
1063        let scoping = ItemScoping {
1064            category: DataCategory::Error,
1065            scoping: Scoping {
1066                organization_id: OrganizationId::new(69420),
1067                project_id: ProjectId::new(42),
1068                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1069                key_id: Some(4711),
1070            },
1071            namespace: MetricNamespaceScoping::None,
1072            dimensions: DimensionMap::default(),
1073        };
1074
1075        let timestamp = UnixTimestamp::from_secs(234_531);
1076        let redis_quota = RedisQuota::new(&quota, 0, &scoping, timestamp).unwrap();
1077        assert_eq!(
1078            redis_quota.key().to_redis_quota_key().redis_key,
1079            "quota:foo{69420}:23453"
1080        );
1081    }
1082
1083    #[tokio::test]
1084    async fn test_large_redis_limit_large() {
1085        let quota = Quota {
1086            id: Some("foo".into()),
1087            categories: DataCategories::new(),
1088            scope: QuotaScope::Organization,
1089            scope_id: None,
1090            window: Some(10),
1091            limit: Some(9223372036854775808), // i64::MAX + 1
1092            reason_code: None,
1093            namespace: None,
1094            group_by: None,
1095        };
1096
1097        let scoping = ItemScoping {
1098            category: DataCategory::Error,
1099            scoping: Scoping {
1100                organization_id: OrganizationId::new(69420),
1101                project_id: ProjectId::new(42),
1102                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1103                key_id: Some(4711),
1104            },
1105            namespace: MetricNamespaceScoping::None,
1106            dimensions: DimensionMap::default(),
1107        };
1108
1109        let timestamp = UnixTimestamp::from_secs(234_531);
1110        let redis_quota = RedisQuota::new(&quota, 0, &scoping, timestamp).unwrap();
1111        assert_eq!(redis_quota.limit(), -1);
1112    }
1113
1114    #[tokio::test]
1115    async fn test_is_rate_limited_script() {
1116        let now = SystemTime::now()
1117            .duration_since(UNIX_EPOCH)
1118            .map(|duration| duration.as_secs())
1119            .unwrap();
1120
1121        let rate_limiter = build_rate_limiter();
1122        let mut conn = rate_limiter.client.get_connection().await.unwrap();
1123
1124        // define a few keys with random seed such that they do not collide with repeated test runs
1125        let foo = format!("foo___{now}");
1126        let r_foo = format!("r:foo___{now}");
1127        let bar = format!("bar___{now}");
1128        let r_bar = format!("r:bar___{now}");
1129        let apple = format!("apple___{now}");
1130        let orange = format!("orange___{now}");
1131        let baz = format!("baz___{now}");
1132
1133        let script = RedisScripts::load_is_rate_limited();
1134
1135        macro_rules! assert_invocation {
1136            ($invocation:expr, $($tt:tt)*) => {{
1137                let result = $invocation
1138                    .invoke_async::<ScriptResult>(&mut conn)
1139                    .await
1140                    .unwrap();
1141
1142                insta::assert_debug_snapshot!(result, $($tt)*);
1143            }};
1144        }
1145
1146        let mut invocation = script.prepare_invoke();
1147        invocation
1148            .key(&foo) // key
1149            .key(&r_foo) // refund key
1150            .key(&bar) // key
1151            .key(&r_bar) // refund key
1152            .arg(1) // limit
1153            .arg(now + 60) // expiry
1154            .arg(1) // quantity
1155            .arg(false) // over accept once
1156            .arg(EMPTY_DIMENSIONS) // dimensions
1157            .arg(1) // max cardinality
1158            .arg(2) // limit
1159            .arg(now + 120) // expiry
1160            .arg(1) // quantity
1161            .arg(false) // over accept once
1162            .arg(EMPTY_DIMENSIONS) // dimensions
1163            .arg(1); // max cardinality
1164
1165        // Craft a new invocation similar to the previous one, but it only applies to the quota
1166        // with a higher limit (2).
1167        let mut invocation2 = script.prepare_invoke();
1168        invocation2
1169            .key(&bar) // key
1170            .key(&r_bar) // refund key
1171            .arg(2) // limit
1172            .arg(now + 120) // expiry
1173            .arg(1) // quantity
1174            .arg(false) // over accept once
1175            .arg(EMPTY_DIMENSIONS) // dimensions
1176            .arg(1); // max cardinality
1177
1178        // 1 quantity used from both quotas.
1179        assert_invocation!(invocation, @r"
1180        ScriptResult(
1181            [
1182                QuotaState {
1183                    is_rejected: false,
1184                    consumed: 1,
1185                },
1186                QuotaState {
1187                    is_rejected: false,
1188                    consumed: 1,
1189                },
1190            ],
1191        )
1192        "
1193        );
1194
1195        // This invocation fails the rate limit on the first quota.
1196        // -> No changes are made to the counters.
1197        assert_invocation!(invocation, @r"
1198        ScriptResult(
1199            [
1200                QuotaState {
1201                    is_rejected: true,
1202                    consumed: 1,
1203                },
1204                QuotaState {
1205                    is_rejected: false,
1206                    consumed: 1,
1207                },
1208            ],
1209        )
1210        "
1211        );
1212
1213        // Another call, same result as before, just making sure there were no changes applied.
1214        assert_invocation!(invocation, @r"
1215        ScriptResult(
1216            [
1217                QuotaState {
1218                    is_rejected: true,
1219                    consumed: 1,
1220                },
1221                QuotaState {
1222                    is_rejected: false,
1223                    consumed: 1,
1224                },
1225            ],
1226        )
1227        "
1228        );
1229
1230        // Using the second invocation which only considers a quota with a higher limit, usage for
1231        // that quota is now 2.
1232        assert_invocation!(invocation2, @r"
1233        ScriptResult(
1234            [
1235                QuotaState {
1236                    is_rejected: false,
1237                    consumed: 2,
1238                },
1239            ],
1240        )
1241        "
1242        );
1243
1244        // Same invocation, but this time the limit is reached, quota should not increase.
1245        assert_invocation!(invocation2, @r"
1246        ScriptResult(
1247            [
1248                QuotaState {
1249                    is_rejected: true,
1250                    consumed: 2,
1251                },
1252            ],
1253        )
1254        "
1255        );
1256
1257        // Check again with the original invocation, this now yields `[1, 2]`.
1258        assert_invocation!(invocation, @r"
1259        ScriptResult(
1260            [
1261                QuotaState {
1262                    is_rejected: true,
1263                    consumed: 1,
1264                },
1265                QuotaState {
1266                    is_rejected: true,
1267                    consumed: 2,
1268                },
1269            ],
1270        )
1271        "
1272        );
1273
1274        assert_eq!(conn.get::<_, String>(&foo).await.unwrap(), "1");
1275
1276        let ttl: u64 = conn.ttl(&foo).await.unwrap();
1277        assert!(ttl >= 59);
1278        assert!(ttl <= 60);
1279
1280        assert_eq!(conn.get::<_, String>(&bar).await.unwrap(), "2");
1281
1282        let ttl: u64 = conn.ttl(&bar).await.unwrap();
1283        assert!(ttl >= 119);
1284        assert!(ttl <= 120);
1285
1286        // make sure "refund/negative" keys haven't been incremented
1287        let () = conn.get(r_foo).await.unwrap();
1288        let () = conn.get(r_bar).await.unwrap();
1289
1290        // Test that refunded quotas work
1291        let () = conn.set(&apple, 5).await.unwrap();
1292
1293        let mut invocation = script.prepare_invoke();
1294        invocation
1295            .key(&orange) // key
1296            .key(&baz) // refund key
1297            .arg(1) // limit
1298            .arg(now + 60) // expiry
1299            .arg(1) // quantity
1300            .arg(false) // over accept once
1301            .arg(EMPTY_DIMENSIONS) // dimensions
1302            .arg(1); // max cardinality
1303
1304        // increment, current quota usage is 1.
1305        assert_invocation!(invocation, @r"
1306        ScriptResult(
1307            [
1308                QuotaState {
1309                    is_rejected: false,
1310                    consumed: 1,
1311                },
1312            ],
1313        )
1314        "
1315        );
1316
1317        // test that it's rate limited without refund.
1318        assert_invocation!(invocation, @r"
1319        ScriptResult(
1320            [
1321                QuotaState {
1322                    is_rejected: true,
1323                    consumed: 1,
1324                },
1325            ],
1326        )
1327        "
1328        );
1329
1330        // Make sure, the counter wasn't incremented.
1331        assert_invocation!(invocation, @r"
1332        ScriptResult(
1333            [
1334                QuotaState {
1335                    is_rejected: true,
1336                    consumed: 1,
1337                },
1338            ],
1339        )
1340        "
1341        );
1342
1343        let mut invocation = script.prepare_invoke();
1344        invocation
1345            .key(&orange) // key
1346            .key(&apple) // refund key
1347            .arg(1) // limit
1348            .arg(now + 60) // expiry
1349            .arg(1) // quantity
1350            .arg(false) // over accept once
1351            .arg(EMPTY_DIMENSIONS) // dimensions
1352            .arg(1); // max cardinality
1353
1354        // test that refund key is used
1355        assert_invocation!(invocation, @r"
1356        ScriptResult(
1357            [
1358                QuotaState {
1359                    is_rejected: false,
1360                    consumed: -3,
1361                },
1362            ],
1363        )
1364        "
1365        );
1366    }
1367
1368    /// Each dimensions key is counted independently within the same Redis key.
1369    #[tokio::test]
1370    async fn test_is_rate_limited_script_dimensions() {
1371        let now = SystemTime::now()
1372            .duration_since(UNIX_EPOCH)
1373            .map(|duration| duration.as_secs())
1374            .unwrap();
1375
1376        let rate_limiter = build_rate_limiter();
1377        let mut conn = rate_limiter.client.get_connection().await.unwrap();
1378
1379        let key = format!("dims___{now}");
1380        let refund_key = format!("r:dims___{now}");
1381
1382        let script = RedisScripts::load_is_rate_limited();
1383
1384        // A single quota with a limit of 1, invoked for two different dimension values.
1385        let invoke = |dims: &str| {
1386            let mut invocation = script.prepare_invoke();
1387
1388            let mut key = key.clone();
1389            if dims != EMPTY_DIMENSIONS {
1390                key += ":hash";
1391            }
1392
1393            invocation
1394                .key(&key) // key
1395                .key(&refund_key) // refund key
1396                .arg(1) // limit
1397                .arg(now + 60) // expiry
1398                .arg(1) // quantity
1399                .arg(false) // over accept once
1400                .arg(dims) // dimensions
1401                .arg(999); // max cardinality
1402            invocation
1403        };
1404
1405        let invoke_async = async |dims: &str, conn: &mut _| {
1406            invoke(dims)
1407                .invoke_async::<ScriptResult>(conn)
1408                .await
1409                .unwrap()
1410                .0
1411        };
1412
1413        // The first check for `:2:a` consumes its bucket...
1414        let result = invoke_async(":2:a", &mut conn).await;
1415        assert!(!result[0].is_rejected);
1416        assert_eq!(result[0].consumed, 1);
1417
1418        // ...and the second one is rejected.
1419        let result = invoke_async(":2:a", &mut conn).await;
1420        assert!(result[0].is_rejected);
1421        assert_eq!(result[0].consumed, 1);
1422
1423        // A different dimensions key is a separate bucket, unaffected by the exhausted one.
1424        let result = invoke_async(":2:b", &mut conn).await;
1425        assert!(!result[0].is_rejected);
1426        assert_eq!(result[0].consumed, 1);
1427
1428        let result = invoke_async(":2:b", &mut conn).await;
1429        assert!(result[0].is_rejected);
1430        assert_eq!(result[0].consumed, 1);
1431
1432        // So is the undimensioned bucket.
1433        let result = invoke_async(EMPTY_DIMENSIONS, &mut conn).await;
1434        assert!(!result[0].is_rejected);
1435        assert_eq!(result[0].consumed, 1);
1436
1437        // All buckets live in the same hash, under the same expiry.
1438        let mut fields: Vec<(String, i64)> = conn.hgetall(&(key.clone() + ":hash")).await.unwrap();
1439        fields.sort();
1440        assert_eq!(
1441            fields,
1442            vec![(":2:a".to_owned(), 1), (":2:b".to_owned(), 1),]
1443        );
1444
1445        let ttl: u64 = conn.ttl(&key).await.unwrap();
1446        assert!(ttl >= 59, "{ttl}");
1447        assert!(ttl <= 60, "{ttl}");
1448    }
1449
1450    /// A quota with a maximum cardinality rejects items which would create a new bucket beyond
1451    /// that cardinality, but keeps serving the buckets which already exist.
1452    #[tokio::test]
1453    async fn test_is_rate_limited_script_dimensions_cardinality() {
1454        let now = SystemTime::now()
1455            .duration_since(UNIX_EPOCH)
1456            .map(|duration| duration.as_secs())
1457            .unwrap();
1458
1459        let rate_limiter = build_rate_limiter();
1460        let mut conn = rate_limiter.client.get_connection().await.unwrap();
1461
1462        let key = format!("cardinality___{now}");
1463        let refund_key = format!("r:cardinality___{now}");
1464
1465        let script = RedisScripts::load_is_rate_limited();
1466
1467        // A generous limit, so that only the cardinality can cause a rejection.
1468        let invoke_async = async |dims: &str, conn: &mut _| {
1469            let mut invocation = script.prepare_invoke();
1470            invocation
1471                .key(&key) // key
1472                .key(&refund_key) // refund key
1473                .arg(100) // limit
1474                .arg(now + 60) // expiry
1475                .arg(1) // quantity
1476                .arg(false) // over accept once
1477                .arg(dims) // dimensions
1478                .arg(2); // max cardinality
1479
1480            invocation
1481                .invoke_async::<ScriptResult>(conn)
1482                .await
1483                .unwrap()
1484                .0
1485        };
1486
1487        // The first two buckets fit within the cardinality of 2.
1488        assert!(!invoke_async(":2:a", &mut conn).await[0].is_rejected);
1489        assert!(!invoke_async(":2:b", &mut conn).await[0].is_rejected);
1490
1491        // A third, new bucket is rejected, even though its own limit is nowhere near reached.
1492        let result = invoke_async(":2:c", &mut conn).await;
1493        assert!(result[0].is_rejected);
1494        assert_eq!(result[0].consumed, 0);
1495
1496        // The existing buckets keep being counted, as they do not add cardinality.
1497        assert!(!invoke_async(":2:a", &mut conn).await[0].is_rejected);
1498        assert!(!invoke_async(":2:b", &mut conn).await[0].is_rejected);
1499
1500        // The rejected bucket was never created.
1501        let mut fields: Vec<(String, i64)> = conn.hgetall(&key).await.unwrap();
1502        fields.sort();
1503        assert_eq!(fields, vec![(":2:a".to_owned(), 2), (":2:b".to_owned(), 2)]);
1504    }
1505
1506    /// A cardinality of `0` rejects every item, and a cardinality of `-1` is unlimited.
1507    #[tokio::test]
1508    async fn test_is_rate_limited_script_dimensions_cardinality_edges() {
1509        let now = SystemTime::now()
1510            .duration_since(UNIX_EPOCH)
1511            .map(|duration| duration.as_secs())
1512            .unwrap();
1513
1514        let rate_limiter = build_rate_limiter();
1515        let mut conn = rate_limiter.client.get_connection().await.unwrap();
1516
1517        let script = RedisScripts::load_is_rate_limited();
1518
1519        let invoke_async = async |key: &str, dims: &str, cardinality: i32, conn: &mut _| {
1520            let mut invocation = script.prepare_invoke();
1521            invocation
1522                .key(key) // key
1523                .key(get_refunded_quota_key(key)) // refund key
1524                .arg(100) // limit
1525                .arg(now + 60) // expiry
1526                .arg(1) // quantity
1527                .arg(false) // over accept once
1528                .arg(dims) // dimensions
1529                .arg(cardinality); // max cardinality
1530
1531            invocation
1532                .invoke_async::<ScriptResult>(conn)
1533                .await
1534                .unwrap()
1535                .0
1536        };
1537
1538        let zero = format!("cardinality_zero___{now}");
1539        assert!(invoke_async(&zero, ":2:a", 0, &mut conn).await[0].is_rejected);
1540        assert_eq!(conn.hlen::<_, u64>(&zero).await.unwrap(), 0);
1541
1542        // `-1` means unlimited, so an arbitrary number of new buckets is accepted.
1543        let unlimited = format!("cardinality_unlimited___{now}");
1544        for i in 0..10 {
1545            let dims = format!(":2:{i}");
1546            assert!(!invoke_async(&unlimited, &dims, -1, &mut conn).await[0].is_rejected);
1547        }
1548        assert_eq!(conn.hlen::<_, u64>(&unlimited).await.unwrap(), 10);
1549    }
1550
1551    /// The refund counter is a plain key, and hence shared by all dimension buckets of a quota.
1552    #[tokio::test]
1553    async fn test_is_rate_limited_script_dimensions_share_refund_key() {
1554        let now = SystemTime::now()
1555            .duration_since(UNIX_EPOCH)
1556            .map(|duration| duration.as_secs())
1557            .unwrap();
1558
1559        let rate_limiter = build_rate_limiter();
1560        let mut conn = rate_limiter.client.get_connection().await.unwrap();
1561
1562        let key = format!("dims_refund___{now}");
1563        let refund_key = get_refunded_quota_key(&key);
1564        let () = conn.set(&refund_key, 5).await.unwrap();
1565
1566        let script = RedisScripts::load_is_rate_limited();
1567
1568        let invoke_async = async |dims: &str, conn: &mut _| {
1569            let mut invocation = script.prepare_invoke();
1570            invocation
1571                .key(&key) // key
1572                .key(&refund_key) // refund key
1573                .arg(1) // limit
1574                .arg(now + 60) // expiry
1575                .arg(1) // quantity
1576                .arg(false) // over accept once
1577                .arg(dims) // dimensions
1578                .arg(999); // max cardinality
1579
1580            invocation
1581                .invoke_async::<ScriptResult>(conn)
1582                .await
1583                .unwrap()
1584                .0
1585        };
1586
1587        // Both buckets are discounted by the full refund, since the refund is not dimensioned.
1588        let result = invoke_async(":2:a", &mut conn).await;
1589        assert!(!result[0].is_rejected);
1590        assert_eq!(result[0].consumed, -4);
1591
1592        let result = invoke_async(":2:b", &mut conn).await;
1593        assert!(!result[0].is_rejected);
1594        assert_eq!(result[0].consumed, -4);
1595    }
1596
1597    /// Usual rate limiting with a cache should just work as expected.
1598    #[tokio::test]
1599    async fn test_quota_with_cache() {
1600        let quotas = &[Quota {
1601            id: Some(format!("test_simple_quota_{}", uuid::Uuid::new_v4()).into()),
1602            categories: DataCategories::new(),
1603            scope: QuotaScope::Organization,
1604            scope_id: None,
1605            limit: Some(50),
1606            window: Some(60),
1607            reason_code: Some(ReasonCode::new("get_lost")),
1608            namespace: None,
1609            group_by: None,
1610        }];
1611
1612        let scoping = ItemScoping {
1613            category: DataCategory::Error,
1614            scoping: Scoping {
1615                organization_id: OrganizationId::new(42),
1616                project_id: ProjectId::new(43),
1617                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1618                key_id: Some(44),
1619            },
1620            namespace: MetricNamespaceScoping::None,
1621            dimensions: DimensionMap::default(),
1622        };
1623
1624        // For this test, with only a single rate limiter accessing Redis and always a quantity of
1625        // `1`, the parameters for the cache should not make any difference.
1626        let rate_limiter = build_rate_limiter().cache(Some(0.1), Some(0.9));
1627
1628        for _ in 0..50 {
1629            let rate_limits = rate_limiter
1630                .is_rate_limited(quotas, &scoping, 1, false)
1631                .await
1632                .unwrap();
1633
1634            assert!(rate_limits.is_empty());
1635        }
1636
1637        let rate_limits: Vec<RateLimit> = rate_limiter
1638            .is_rate_limited(quotas, &scoping, 1, false)
1639            .await
1640            .expect("rate limiting failed")
1641            .into_iter()
1642            .collect();
1643
1644        assert_eq!(
1645            rate_limits,
1646            vec![RateLimit {
1647                categories: DataCategories::new(),
1648                scope: RateLimitScope::Organization(OrganizationId::new(42)),
1649                reason_code: Some(ReasonCode::new("get_lost")),
1650                retry_after: rate_limits[0].retry_after,
1651                namespaces: smallvec![],
1652                dimensional: false,
1653            }]
1654        );
1655    }
1656
1657    #[tokio::test]
1658    async fn test_quota_with_cache_slightly_over_account() {
1659        let window = 60;
1660        let limit = 50 * window;
1661
1662        let quotas = &[Quota {
1663            id: Some(format!("test_simple_quota_{}", uuid::Uuid::new_v4()).into()),
1664            categories: DataCategories::new(),
1665            scope: QuotaScope::Organization,
1666            scope_id: None,
1667            limit: Some(limit),
1668            window: Some(window),
1669            reason_code: Some(ReasonCode::new("get_lost")),
1670            namespace: None,
1671            group_by: None,
1672        }];
1673
1674        let scoping = ItemScoping {
1675            category: DataCategory::Error,
1676            scoping: Scoping {
1677                organization_id: OrganizationId::new(42),
1678                project_id: ProjectId::new(43),
1679                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1680                key_id: Some(44),
1681            },
1682            namespace: MetricNamespaceScoping::None,
1683            dimensions: DimensionMap::default(),
1684        };
1685
1686        // 10% Quota cache.
1687        let rate_limiter1 = build_rate_limiter().cache(Some(0.1), None);
1688        let rate_limiter2 = build_rate_limiter().cache(Some(0.1), None);
1689
1690        // Prime the cache.
1691        let rate_limits = rate_limiter1
1692            .is_rate_limited(quotas, &scoping, 1, false)
1693            .await
1694            .unwrap();
1695        assert!(rate_limits.is_empty());
1696        // Reserve 3 out 5 in the cache.
1697        let rate_limits = rate_limiter1
1698            .is_rate_limited(quotas, &scoping, 3, false)
1699            .await
1700            .unwrap();
1701        assert!(rate_limits.is_empty());
1702
1703        // Consume right up to the limit on the other limiter
1704        let rate_limits = rate_limiter2
1705            .is_rate_limited(quotas, &scoping, limit as usize - 1, false)
1706            .await
1707            .unwrap();
1708        assert!(rate_limits.is_empty());
1709
1710        // There is still one more slot in the cache.
1711        let rate_limits = rate_limiter1
1712            .is_rate_limited(quotas, &scoping, 1, false)
1713            .await
1714            .unwrap();
1715        assert!(rate_limits.is_empty());
1716
1717        // This should now rate limit, as the cache is exhausted and Redis is checked.
1718        let rate_limits: Vec<RateLimit> = rate_limiter1
1719            .is_rate_limited(quotas, &scoping, 1, false)
1720            .await
1721            .unwrap()
1722            .into_iter()
1723            .collect();
1724
1725        assert_eq!(
1726            rate_limits,
1727            vec![RateLimit {
1728                categories: DataCategories::new(),
1729                scope: RateLimitScope::Organization(OrganizationId::new(42)),
1730                reason_code: Some(ReasonCode::new("get_lost")),
1731                retry_after: rate_limits[0].retry_after,
1732                namespaces: smallvec![],
1733                dimensional: false,
1734            }]
1735        );
1736    }
1737
1738    #[tokio::test]
1739    async fn test_quota_dimensions() {
1740        let quotas = &[
1741            Quota {
1742                id: Some(format!("test_quota_go_over{}", uuid::Uuid::new_v4()).into()),
1743                categories: DataCategories::new().add(DataCategory::Monitor).unwrap(),
1744                scope: QuotaScope::Project,
1745                scope_id: None,
1746                limit: Some(2),
1747                window: Some(60),
1748                reason_code: Some(ReasonCode::new("get_lost")),
1749                namespace: None,
1750                group_by: GroupBy {
1751                    max_cardinality: 999,
1752                    dimensions: BTreeSet::from([
1753                        Dimension::CheckInEnvironment,
1754                        Dimension::CheckInSlug,
1755                    ])
1756                    .into(),
1757                }
1758                .into(),
1759            },
1760            Quota {
1761                id: Some(format!("test_quota_wont_go_over{}", uuid::Uuid::new_v4()).into()),
1762                categories: DataCategories::new().add(DataCategory::Monitor).unwrap(),
1763                scope: QuotaScope::Project,
1764                scope_id: None,
1765                limit: Some(2),
1766                window: Some(60),
1767                reason_code: Some(ReasonCode::new("get_lost")),
1768                namespace: None,
1769                group_by: None,
1770            },
1771        ];
1772
1773        let scoping = ItemScoping {
1774            category: DataCategory::Monitor,
1775            scoping: Scoping {
1776                organization_id: OrganizationId::new(42),
1777                project_id: ProjectId::new(43),
1778                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1779                key_id: Some(44),
1780            },
1781            namespace: MetricNamespaceScoping::None,
1782            dimensions: DimensionMap::from([
1783                (Dimension::CheckInEnvironment, "us".to_owned()),
1784                (Dimension::CheckInSlug, "cron1".to_owned()),
1785            ]),
1786        };
1787
1788        let no_dims_scoping = ItemScoping {
1789            category: DataCategory::Monitor,
1790            scoping: Scoping {
1791                organization_id: OrganizationId::new(42),
1792                project_id: ProjectId::new(43),
1793                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1794                key_id: Some(44),
1795            },
1796            namespace: MetricNamespaceScoping::None,
1797            dimensions: DimensionMap::default(),
1798        };
1799
1800        let rate_limiter = build_rate_limiter();
1801
1802        // limit is 2, so first call not rate limited
1803        let is_limited = rate_limiter
1804            .is_rate_limited(quotas, &scoping, 1, true)
1805            .await
1806            .unwrap()
1807            .is_limited();
1808        assert!(!is_limited);
1809
1810        let is_limited = rate_limiter
1811            .is_rate_limited(quotas, &scoping, 2, false)
1812            .await
1813            .unwrap()
1814            .is_limited();
1815        assert!(is_limited);
1816
1817        // make sure the non-dimensioned monitor is not limited...
1818        let is_limited = rate_limiter
1819            .is_rate_limited(quotas, &no_dims_scoping, 1, true)
1820            .await
1821            .unwrap()
1822            .is_limited();
1823        assert!(!is_limited);
1824
1825        // ...until we invoke again
1826        let is_limited = rate_limiter
1827            .is_rate_limited(quotas, &no_dims_scoping, 2, false)
1828            .await
1829            .unwrap()
1830            .is_limited();
1831        assert!(is_limited);
1832    }
1833
1834    /// Builds a project-scoped monitor quota with the passed dimensions and limit.
1835    fn monitor_quota(limit: u64, group_by: Option<GroupBy>) -> Quota {
1836        Quota {
1837            id: Some(format!("test_dimensions_{}", uuid::Uuid::new_v4()).into()),
1838            categories: DataCategories::new().add(DataCategory::Monitor).unwrap(),
1839            scope: QuotaScope::Project,
1840            scope_id: None,
1841            limit: Some(limit),
1842            window: Some(60),
1843            reason_code: Some(ReasonCode::new("get_lost")),
1844            namespace: None,
1845            group_by,
1846        }
1847    }
1848
1849    /// Builds a monitor item scoping with the passed dimensions.
1850    fn monitor_scoping(dimensions: &[(Dimension, &str)]) -> ItemScoping {
1851        let mut owned_dims: Vec<_> = dimensions
1852            .iter()
1853            .map(|(d, v)| (*d, (*v).to_owned()))
1854            .collect();
1855        ItemScoping {
1856            category: DataCategory::Monitor,
1857            scoping: Scoping {
1858                organization_id: OrganizationId::new(42),
1859                project_id: ProjectId::new(43),
1860                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1861                key_id: Some(44),
1862            },
1863            namespace: MetricNamespaceScoping::None,
1864            dimensions: DimensionMap::from_iter(owned_dims.drain(..)),
1865        }
1866    }
1867
1868    /// Items which differ in any of the quota's dimensions consume separate buckets.
1869    #[tokio::test]
1870    async fn test_quota_dimensions_are_independent_buckets() {
1871        let quotas = &[monitor_quota(
1872            1,
1873            GroupBy {
1874                max_cardinality: 999,
1875                dimensions: BTreeSet::from([Dimension::CheckInEnvironment, Dimension::CheckInSlug])
1876                    .into(),
1877            }
1878            .into(),
1879        )];
1880
1881        let rate_limiter = build_rate_limiter();
1882
1883        // Every one of these differs from the others in at least one dimension, so each one
1884        // gets the full limit of 1 for itself.
1885        let distinct = [
1886            [
1887                (Dimension::CheckInEnvironment, "prod"),
1888                (Dimension::CheckInSlug, "cron1"),
1889            ],
1890            [
1891                (Dimension::CheckInEnvironment, "prod"),
1892                (Dimension::CheckInSlug, "cron2"),
1893            ],
1894            [
1895                (Dimension::CheckInEnvironment, "dev"),
1896                (Dimension::CheckInSlug, "cron1"),
1897            ],
1898        ];
1899
1900        for dims in &distinct {
1901            let scoping = monitor_scoping(dims);
1902
1903            let rate_limits = rate_limiter
1904                .is_rate_limited(quotas, &scoping, 1, false)
1905                .await
1906                .unwrap();
1907            assert!(!rate_limits.is_limited(), "first check for {dims:?}");
1908
1909            // The bucket for these dimensions is now exhausted.
1910            let rate_limits: Vec<RateLimit> = rate_limiter
1911                .is_rate_limited(quotas, &scoping, 1, false)
1912                .await
1913                .unwrap()
1914                .into_iter()
1915                .collect();
1916
1917            assert_eq!(
1918                rate_limits,
1919                vec![RateLimit {
1920                    categories: DataCategories::new().add(DataCategory::Monitor).unwrap(),
1921                    scope: RateLimitScope::Project(ProjectId::new(43)),
1922                    reason_code: Some(ReasonCode::new("get_lost")),
1923                    retry_after: rate_limits[0].retry_after,
1924                    namespaces: smallvec![],
1925                    dimensional: true,
1926                }],
1927                "second check for {dims:?}"
1928            );
1929        }
1930    }
1931
1932    /// Dimensions the quota does not key on do not split the bucket.
1933    #[tokio::test]
1934    async fn test_quota_dimensions_ignores_unrelated_dimensions() {
1935        let quotas = &[monitor_quota(
1936            2,
1937            GroupBy {
1938                max_cardinality: 999,
1939                dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
1940            }
1941            .into(),
1942        )];
1943
1944        let rate_limiter = build_rate_limiter();
1945
1946        // The quota only keys on the slug, so these two items share a bucket despite their
1947        // differing environments.
1948        let prod = monitor_scoping(&[
1949            (Dimension::CheckInEnvironment, "prod"),
1950            (Dimension::CheckInSlug, "cron1"),
1951        ]);
1952        let dev = monitor_scoping(&[
1953            (Dimension::CheckInEnvironment, "dev"),
1954            (Dimension::CheckInSlug, "cron1"),
1955        ]);
1956
1957        assert!(
1958            !rate_limiter
1959                .is_rate_limited(quotas, &prod, 1, false)
1960                .await
1961                .unwrap()
1962                .is_limited()
1963        );
1964        assert!(
1965            !rate_limiter
1966                .is_rate_limited(quotas, &dev, 1, false)
1967                .await
1968                .unwrap()
1969                .is_limited()
1970        );
1971
1972        // Two of the limit of 2 are consumed, so both items are limited from here on.
1973        assert!(
1974            rate_limiter
1975                .is_rate_limited(quotas, &prod, 1, false)
1976                .await
1977                .unwrap()
1978                .is_limited()
1979        );
1980        assert!(
1981            rate_limiter
1982                .is_rate_limited(quotas, &dev, 1, false)
1983                .await
1984                .unwrap()
1985                .is_limited()
1986        );
1987    }
1988
1989    /// A quota with dimensions never applies to an item which does not carry them.
1990    #[tokio::test]
1991    async fn test_quota_dimensions_skipped_for_undimensioned_item() {
1992        let quotas = &[monitor_quota(
1993            1,
1994            GroupBy {
1995                max_cardinality: 999,
1996                dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
1997            }
1998            .into(),
1999        )];
2000
2001        let rate_limiter = build_rate_limiter();
2002
2003        // The quota does not match, so no amount of traffic can exhaust it.
2004        for _ in 0..5 {
2005            assert!(
2006                !rate_limiter
2007                    .is_rate_limited(quotas, &monitor_scoping(&[]), 1, false)
2008                    .await
2009                    .unwrap()
2010                    .is_limited()
2011            );
2012        }
2013
2014        // An item missing only one of two required dimensions is skipped the same way.
2015        let quotas = &[monitor_quota(
2016            1,
2017            GroupBy {
2018                max_cardinality: 999,
2019                dimensions: BTreeSet::from([Dimension::CheckInEnvironment, Dimension::CheckInSlug])
2020                    .into(),
2021            }
2022            .into(),
2023        )];
2024
2025        for _ in 0..5 {
2026            assert!(
2027                !rate_limiter
2028                    .is_rate_limited(
2029                        quotas,
2030                        &monitor_scoping(&[(Dimension::CheckInSlug, "cron1")]),
2031                        1,
2032                        false
2033                    )
2034                    .await
2035                    .unwrap()
2036                    .is_limited()
2037            );
2038        }
2039    }
2040
2041    /// Once the quota's cardinality is used up, items with new dimension values are limited,
2042    /// while the existing ones keep their quota.
2043    #[tokio::test]
2044    async fn test_quota_dimensions_max_cardinality() {
2045        let quotas = &[monitor_quota(
2046            100,
2047            GroupBy {
2048                max_cardinality: 2,
2049                dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
2050            }
2051            .into(),
2052        )];
2053
2054        let rate_limiter = build_rate_limiter();
2055
2056        let cron1 = monitor_scoping(&[(Dimension::CheckInSlug, "cron1")]);
2057        let cron2 = monitor_scoping(&[(Dimension::CheckInSlug, "cron2")]);
2058        let cron3 = monitor_scoping(&[(Dimension::CheckInSlug, "cron3")]);
2059
2060        // Two distinct slugs fit into the cardinality of 2.
2061        assert!(
2062            !rate_limiter
2063                .is_rate_limited(quotas, &cron1, 1, false)
2064                .await
2065                .unwrap()
2066                .is_limited()
2067        );
2068        assert!(
2069            !rate_limiter
2070                .is_rate_limited(quotas, &cron2, 1, false)
2071                .await
2072                .unwrap()
2073                .is_limited()
2074        );
2075
2076        // The third one is limited, despite the limit of 100 being nowhere near reached.
2077        let rate_limits: Vec<RateLimit> = rate_limiter
2078            .is_rate_limited(quotas, &cron3, 1, false)
2079            .await
2080            .unwrap()
2081            .into_iter()
2082            .collect();
2083
2084        assert_eq!(
2085            rate_limits,
2086            vec![RateLimit {
2087                categories: DataCategories::new().add(DataCategory::Monitor).unwrap(),
2088                scope: RateLimitScope::Project(ProjectId::new(43)),
2089                reason_code: Some(ReasonCode::new("get_lost")),
2090                retry_after: rate_limits[0].retry_after,
2091                namespaces: smallvec![],
2092                dimensional: true,
2093            }]
2094        );
2095
2096        // The slugs which made it in are unaffected.
2097        assert!(
2098            !rate_limiter
2099                .is_rate_limited(quotas, &cron1, 1, false)
2100                .await
2101                .unwrap()
2102                .is_limited()
2103        );
2104        assert!(
2105            !rate_limiter
2106                .is_rate_limited(quotas, &cron2, 1, false)
2107                .await
2108                .unwrap()
2109                .is_limited()
2110        );
2111    }
2112
2113    /// A quota with a cardinality of `0` rejects every dimensioned item.
2114    #[tokio::test]
2115    async fn test_quota_dimensions_zero_cardinality() {
2116        let quotas = &[monitor_quota(
2117            100,
2118            GroupBy {
2119                max_cardinality: 0,
2120                dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
2121            }
2122            .into(),
2123        )];
2124
2125        let rate_limiter = build_rate_limiter();
2126
2127        assert!(
2128            rate_limiter
2129                .is_rate_limited(
2130                    quotas,
2131                    &monitor_scoping(&[(Dimension::CheckInSlug, "cron1")]),
2132                    1,
2133                    false
2134                )
2135                .await
2136                .unwrap()
2137                .is_limited()
2138        );
2139    }
2140
2141    /// Every dimension bucket gets its own limit, even when the opportunistic cache is enabled.
2142    ///
2143    /// The cache is keyed on the quota's dimension *names*, not on the values of an individual
2144    /// item, so all buckets of a quota share one cache entry. This test pins down that sharing
2145    /// the entry does not make the buckets share their limit.
2146    #[tokio::test]
2147    async fn test_quota_dimensions_with_cache() {
2148        let limit = 50 * 60;
2149
2150        let quotas = &[monitor_quota(
2151            limit,
2152            GroupBy {
2153                max_cardinality: 999,
2154                dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
2155            }
2156            .into(),
2157        )];
2158
2159        let rate_limiter = build_rate_limiter().cache(Some(0.1), Some(0.9));
2160
2161        let crons = [
2162            monitor_scoping(&[(Dimension::CheckInSlug, "cron1")]),
2163            monitor_scoping(&[(Dimension::CheckInSlug, "cron2")]),
2164        ];
2165
2166        // Interleave the two slugs, so that both of them go through the shared cache entry, and
2167        // send a third more than either of them is allowed.
2168        let mut accepted = [0u64; 2];
2169        for _ in 0..(limit + limit / 2) {
2170            for (scoping, accepted) in crons.iter().zip(accepted.iter_mut()) {
2171                if !rate_limiter
2172                    .is_rate_limited(quotas, scoping, 1, false)
2173                    .await
2174                    .unwrap()
2175                    .is_limited()
2176                {
2177                    *accepted += 1;
2178                }
2179            }
2180        }
2181
2182        // The cache accepts opportunistically, so allow for the same 1% of over-acceptance the
2183        // undimensioned cache tests allow for.
2184        let tolerance = limit / 100;
2185        for accepted in accepted {
2186            assert!(
2187                accepted.abs_diff(limit) <= tolerance,
2188                "accepted {accepted} of a limit of {limit}"
2189            );
2190        }
2191    }
2192
2193    /// Dimensioned and undimensioned quotas on the same item are counted separately, and either
2194    /// of them can rate limit the item on its own.
2195    #[tokio::test]
2196    async fn test_quota_dimensions_alongside_undimensioned_quota() {
2197        // The undimensioned quota is the more generous of the two.
2198        let quotas = &[
2199            monitor_quota(
2200                1,
2201                GroupBy {
2202                    max_cardinality: 999,
2203                    dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
2204                }
2205                .into(),
2206            ),
2207            monitor_quota(3, None),
2208        ];
2209
2210        let rate_limiter = build_rate_limiter();
2211
2212        let cron1 = monitor_scoping(&[(Dimension::CheckInSlug, "cron1")]);
2213        let cron2 = monitor_scoping(&[(Dimension::CheckInSlug, "cron2")]);
2214        let cron3 = monitor_scoping(&[(Dimension::CheckInSlug, "cron3")]);
2215        let cron4 = monitor_scoping(&[(Dimension::CheckInSlug, "cron4")]);
2216
2217        // Each new slug has its own per-dimension bucket, and all of them share the
2218        // undimensioned one.
2219        for scoping in [&cron1, &cron2, &cron3] {
2220            assert!(
2221                !rate_limiter
2222                    .is_rate_limited(quotas, scoping, 1, false)
2223                    .await
2224                    .unwrap()
2225                    .is_limited()
2226            );
2227        }
2228
2229        // The per-dimension bucket of a repeated slug is exhausted.
2230        assert!(
2231            rate_limiter
2232                .is_rate_limited(quotas, &cron1, 1, false)
2233                .await
2234                .unwrap()
2235                .is_limited()
2236        );
2237
2238        // A fresh slug has quota of its own, but the shared, undimensioned quota of 3 is now
2239        // used up, so it is limited all the same.
2240        assert!(
2241            rate_limiter
2242                .is_rate_limited(quotas, &cron4, 1, false)
2243                .await
2244                .unwrap()
2245                .is_limited()
2246        );
2247    }
2248
2249    /// Regression test for the project-wide over-application of a per-monitor quota.
2250    ///
2251    /// Exhausting one monitor's bucket must not produce anything which can be cached on the
2252    /// project, because a cached limit would then drop check-ins for every other monitor and be
2253    /// propagated to SDKs and downstream Relays as a scope-wide monitor limit.
2254    #[tokio::test]
2255    async fn test_quota_dimensions_are_not_cacheable() {
2256        let quotas = &[monitor_quota(
2257            1,
2258            GroupBy {
2259                max_cardinality: 999,
2260                dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
2261            }
2262            .into(),
2263        )];
2264
2265        let rate_limiter = build_rate_limiter();
2266
2267        let cron1 = monitor_scoping(&[(Dimension::CheckInSlug, "cron1")]);
2268        let cron2 = monitor_scoping(&[(Dimension::CheckInSlug, "cron2")]);
2269
2270        // Exhaust the bucket of a single monitor.
2271        rate_limiter
2272            .is_rate_limited(quotas, &cron1, 1, false)
2273            .await
2274            .unwrap();
2275        let limits = rate_limiter
2276            .is_rate_limited(quotas, &cron1, 1, false)
2277            .await
2278            .unwrap();
2279
2280        // The limit is enforced against the check-in which produced it.
2281        assert!(limits.is_limited());
2282
2283        // It would apply to every other monitor in the project if it were retained, so none of
2284        // it survives the filter.
2285        assert!(limits.cacheable().is_empty());
2286        assert!(!limits.cacheable().check(&cron2).is_limited());
2287
2288        // The other monitor still has its own bucket in Redis.
2289        assert!(
2290            !rate_limiter
2291                .is_rate_limited(quotas, &cron2, 1, false)
2292                .await
2293                .unwrap()
2294                .is_limited()
2295        );
2296    }
2297
2298    /// An undimensioned quota on the same category stays cacheable.
2299    #[tokio::test]
2300    async fn test_quota_without_dimensions_is_cacheable() {
2301        let quotas = &[monitor_quota(1, None)];
2302
2303        let rate_limiter = build_rate_limiter();
2304
2305        let cron1 = monitor_scoping(&[(Dimension::CheckInSlug, "cron1")]);
2306
2307        rate_limiter
2308            .is_rate_limited(quotas, &cron1, 1, false)
2309            .await
2310            .unwrap();
2311        let limits = rate_limiter
2312            .is_rate_limited(quotas, &cron1, 1, false)
2313            .await
2314            .unwrap();
2315
2316        // This limit really does apply to the whole project, so it is kept for the cache even
2317        // though the item which produced it carried dimensions.
2318        assert!(limits.is_limited());
2319        assert!(!limits.cacheable().is_empty());
2320    }
2321}