Skip to main content

relay_quotas/
rate_limit.rs

1use std::collections::{BTreeMap, BTreeSet, BinaryHeap};
2use std::fmt;
3use std::ops::Deref;
4use std::str::FromStr;
5use std::sync::{Arc, Mutex, PoisonError};
6use std::time::{Duration, Instant};
7
8use relay_base_schema::data_category::DataCategory;
9use relay_base_schema::metrics::MetricNamespace;
10use relay_base_schema::organization::OrganizationId;
11use relay_base_schema::project::{ProjectId, ProjectKey};
12use smallvec::SmallVec;
13
14use crate::quota::{DataCategories, ItemScoping, Quota, QuotaScope, ReasonCode, Scoping};
15use crate::{Dimension, DimensionMap, REJECT_ALL_SECS};
16
17/// A monotonic expiration marker for rate limits.
18///
19/// [`RetryAfter`] represents a point in time when a rate limit expires. It allows checking
20/// whether the rate limit is still active or has expired, and calculating the remaining time
21/// until expiration.
22#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
23pub struct RetryAfter {
24    when: Instant,
25}
26
27impl RetryAfter {
28    /// Creates a new [`RetryAfter`] instance that expires after the specified number of seconds.
29    #[inline]
30    pub fn from_secs(seconds: u64) -> Self {
31        let now = Instant::now();
32        let when = now.checked_add(Duration::from_secs(seconds)).unwrap_or(now);
33        Self { when }
34    }
35
36    /// Returns the remaining duration until the rate limit expires using the specified instant
37    /// as the reference point.
38    ///
39    /// If the rate limit has already expired at the given instant, returns `None`.
40    #[inline]
41    pub fn remaining_at(self, at: Instant) -> Option<Duration> {
42        if at >= self.when {
43            None
44        } else {
45            Some(self.when - at)
46        }
47    }
48
49    /// Returns the remaining duration until the rate limit expires.
50    ///
51    /// This uses the current instant as the reference point. If the rate limit has already
52    /// expired, returns `None`.
53    #[inline]
54    pub fn remaining(self) -> Option<Duration> {
55        self.remaining_at(Instant::now())
56    }
57
58    /// Returns the remaining seconds until the rate limit expires using the specified instant
59    /// as the reference point.
60    ///
61    /// This method rounds up to the next second to ensure that rate limits are strictly enforced.
62    /// If the rate limit has already expired, returns `0`.
63    #[inline]
64    pub fn remaining_seconds_at(self, at: Instant) -> u64 {
65        match self.remaining_at(at) {
66            // Compensate for the missing subsec part by adding 1s
67            Some(duration) if duration.subsec_nanos() == 0 => duration.as_secs(),
68            Some(duration) => duration.as_secs() + 1,
69            None => 0,
70        }
71    }
72
73    /// Returns the remaining seconds until the rate limit expires.
74    ///
75    /// This uses the current instant as the reference point. If the rate limit
76    /// has already expired, returns `0`.
77    #[inline]
78    pub fn remaining_seconds(self) -> u64 {
79        self.remaining_seconds_at(Instant::now())
80    }
81
82    /// Returns whether this rate limit has expired at the specified instant.
83    #[inline]
84    pub fn expired_at(self, at: Instant) -> bool {
85        self.remaining_at(at).is_none()
86    }
87
88    /// Returns whether this rate limit has expired at the current instant.
89    #[inline]
90    pub fn expired(self) -> bool {
91        self.remaining_at(Instant::now()).is_none()
92    }
93}
94
95impl fmt::Debug for RetryAfter {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        match self.remaining_seconds() {
98            0 => write!(f, "RetryAfter(expired)"),
99            remaining => write!(f, "RetryAfter({remaining}s)"),
100        }
101    }
102}
103
104#[cfg(test)]
105impl serde::Serialize for RetryAfter {
106    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
107    where
108        S: serde::Serializer,
109    {
110        use serde::ser::SerializeTupleStruct;
111        let mut tup = serializer.serialize_tuple_struct("RetryAfter", 1)?;
112        tup.serialize_field(&self.remaining_seconds())?;
113        tup.end()
114    }
115}
116
117/// Error that occurs when parsing a [`RetryAfter`] from a string fails.
118#[derive(Debug)]
119pub enum InvalidRetryAfter {
120    /// The supplied delay in seconds was not valid.
121    InvalidDelay(std::num::ParseFloatError),
122}
123
124impl FromStr for RetryAfter {
125    type Err = InvalidRetryAfter;
126
127    fn from_str(s: &str) -> Result<Self, Self::Err> {
128        let float = s.parse::<f64>().map_err(InvalidRetryAfter::InvalidDelay)?;
129        let seconds = float.max(0.0).ceil() as u64;
130        Ok(RetryAfter::from_secs(seconds))
131    }
132}
133
134/// The scope that a rate limit applies to.
135///
136/// Unlike [`QuotaScope`], which only declares the class of the scope, this enum carries
137/// the specific identifiers of the individual scopes that a rate limit applies to.
138///
139/// Rate limits can be applied at different levels of granularity, from global (affecting all data)
140/// down to a specific project key.
141#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)]
142#[cfg_attr(test, derive(serde::Serialize))]
143pub enum RateLimitScope {
144    /// An organization with identifier.
145    Organization(OrganizationId),
146    /// A project with identifier.
147    Project(ProjectId),
148    /// A DSN public key.
149    Key(ProjectKey),
150}
151
152impl RateLimitScope {
153    /// Creates a rate limiting scope from the given item scoping for a specific quota.
154    ///
155    /// This extracts the appropriate scope identifier based on the quota's scope type.
156    /// For unknown scopes, it assumes the most specific scope (Key).
157    pub fn for_quota(scoping: &Scoping, scope: QuotaScope) -> Self {
158        match scope {
159            QuotaScope::Organization => Self::Organization(scoping.organization_id),
160            QuotaScope::Project => Self::Project(scoping.project_id),
161            QuotaScope::Key => Self::Key(scoping.project_key),
162            // For unknown scopes, assume the most specific scope:
163            QuotaScope::Unknown => Self::Key(scoping.project_key),
164        }
165    }
166
167    /// Returns the canonical name of this scope.
168    ///
169    /// This corresponds to the name of the corresponding [`QuotaScope`].
170    pub fn name(&self) -> &'static str {
171        match *self {
172            Self::Key(_) => QuotaScope::Key.name(),
173            Self::Project(_) => QuotaScope::Project.name(),
174            Self::Organization(_) => QuotaScope::Organization.name(),
175        }
176    }
177}
178
179/// An active rate limit that restricts data ingestion.
180///
181/// A rate limit defines restrictions for specific data categories within a particular scope.
182/// It includes an expiration time after which the limit is no longer enforced.
183///
184/// Rate limits can be created from [`Quota`]s or directly constructed with the needed parameters.
185#[derive(Clone, Debug, PartialEq)]
186#[cfg_attr(test, derive(serde::Serialize))]
187pub struct RateLimit {
188    /// A set of data categories that this quota applies to. If empty, this rate limit
189    /// applies to all data categories.
190    pub categories: DataCategories,
191
192    /// The scope of this rate limit.
193    pub scope: RateLimitScope,
194
195    /// A machine-readable reason indicating which quota caused this rate limit.
196    pub reason_code: Option<ReasonCode>,
197
198    /// A marker when this rate limit expires.
199    pub retry_after: RetryAfter,
200
201    /// The metric namespace of this rate limit.
202    ///
203    /// Only relevant for data categories of type metric bucket. If empty,
204    /// this rate limit applies to metrics of all namespaces.
205    pub namespaces: SmallVec<[MetricNamespace; 1]>,
206
207    /// The dimensions that this rate-limit applies to.  If empty, this rate limit applies
208    /// to any item matching the other properties of this rate limit.
209    pub dimensions: DimensionMap,
210}
211
212impl RateLimit {
213    /// Creates a new rate limit from the given [`Quota`].
214    ///
215    /// This builds a rate limit with the appropriate scope derived from the quota and scoping
216    /// information. The categories and other properties are copied from the quota.
217    pub fn from_quota(quota: &Quota, scoping: &ItemScoping, retry_after: RetryAfter) -> Self {
218        let dimensions = if let Some(group_by) = &quota.group_by {
219            group_by
220                .dimensions
221                .iter()
222                .filter_map(|dim| Some((*dim, scoping.dimensions.get(dim)?.value.clone())))
223                .collect()
224        } else {
225            DimensionMap::default()
226        };
227
228        Self {
229            categories: quota.categories,
230            scope: RateLimitScope::for_quota(scoping, quota.scope),
231            reason_code: quota.reason_code.clone(),
232            retry_after,
233            namespaces: quota.namespace.into_iter().collect(),
234            dimensions,
235        }
236    }
237
238    /// Checks whether this rate limit applies to the given item.
239    ///
240    /// A rate limit applies if its scope matches the item's scope, and the item's
241    /// category and namespace match those of the rate limit.
242    pub fn matches(&self, scoping: &ItemScoping) -> bool {
243        self.matches_scope(scoping)
244            && scoping.matches_categories(self.categories)
245            && scoping.matches_namespaces(&self.namespaces)
246            && scoping.satisfies_quota_dimensions(&self.dimensions)
247    }
248
249    /// Returns `true` if the rate limiting scope matches the given item.
250    fn matches_scope(&self, scoping: &ItemScoping) -> bool {
251        match self.scope {
252            RateLimitScope::Organization(org_id) => scoping.organization_id == org_id,
253            RateLimitScope::Project(project_id) => scoping.project_id == project_id,
254            RateLimitScope::Key(key) => scoping.project_key == key,
255        }
256    }
257}
258
259/// A wrapper for RateLimits that can only be constructed by [`RateLimits::propagatable`], ensuring
260/// that we do not include any dimensioned rate limits in it.
261#[derive(Debug)]
262pub struct PropagatableRateLimits(RateLimits);
263
264impl Deref for PropagatableRateLimits {
265    type Target = RateLimits;
266
267    fn deref(&self) -> &Self::Target {
268        &self.0
269    }
270}
271
272#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)]
273#[cfg_attr(test, derive(serde::Serialize))]
274struct RateLimitKey {
275    scope: RateLimitScope,
276    dimension_hashes: u64,
277}
278
279/// A collection of scoped rate limits.
280///
281/// [`RateLimits`] manages a set of active rate limits that can be checked against
282/// incoming data.
283///
284/// The collection can be empty, indicated by [`is_ok`](Self::is_ok), meaning no rate limits
285/// are currently active.
286#[derive(Clone, Debug, Default)]
287#[cfg_attr(test, derive(serde::Serialize))]
288pub struct RateLimits {
289    limits: BTreeMap<RateLimitKey, Vec<RateLimit>>,
290
291    dimension_sets: BTreeSet<BTreeSet<Dimension>>,
292
293    expiry_heap: BinaryHeap<HeapItem>,
294}
295
296/// An item stored in the expiry heap, ordered on RetryAfter, but also storing a RateLimitKey that
297/// is used to index into the limits map.
298#[derive(Clone, Debug, PartialEq, Eq)]
299#[cfg_attr(test, derive(serde::Serialize))]
300struct HeapItem {
301    retry_after: RetryAfter,
302    rl_key: RateLimitKey,
303}
304
305impl Ord for HeapItem {
306    fn cmp(&self, other: &Self) -> std::cmp::Ordering {
307        // Reverse for a min heap.
308        self.retry_after.cmp(&other.retry_after).reverse()
309    }
310}
311
312impl PartialOrd for HeapItem {
313    fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
314        Some(self.cmp(other))
315    }
316}
317
318impl RateLimits {
319    /// Creates an empty [`RateLimits`] instance.
320    pub fn new() -> Self {
321        Self::default()
322    }
323
324    /// Adds a limit to this collection.
325    ///
326    /// If a rate limit with an overlapping scope already exists, the `retry_after` count is merged
327    /// with the existing limit. Otherwise, the new rate limit is added.
328    pub fn add(&mut self, limit: RateLimit) {
329        let RateLimit {
330            categories,
331            scope,
332            reason_code: _,
333            retry_after: _,
334            namespaces,
335            dimensions,
336        } = &limit;
337
338        let rate_limit_key = RateLimitKey {
339            scope: *scope,
340            dimension_hashes: dimensions.dimension_hashes(),
341        };
342
343        self.dimension_sets
344            .insert(dimensions.keys().copied().collect());
345
346        let dims = self.limits.entry(rate_limit_key).or_default();
347
348        let limit_opt = dims.iter_mut().find(|l| {
349            *categories == l.categories
350                && *namespaces == l.namespaces
351                && *dimensions == l.dimensions
352        });
353
354        match limit_opt {
355            None => {
356                self.expiry_heap.push(HeapItem {
357                    retry_after: limit.retry_after,
358                    rl_key: rate_limit_key,
359                });
360                dims.push(limit);
361            }
362            Some(existing) if existing.retry_after < limit.retry_after => {
363                // Go ahead and push a heap item with the same RateLimitKey; much cheaper than
364                // trying to re-order the heap, and won't do anything when it comes time to pop
365                // because we still check the actual RateLimit item to make sure it's expired.
366                self.expiry_heap.push(HeapItem {
367                    retry_after: limit.retry_after,
368                    rl_key: rate_limit_key,
369                });
370                *existing = limit;
371            }
372            Some(_) => (), // keep existing, longer limit
373        }
374    }
375
376    /// Merges all limits from another [`RateLimits`] instance into this one.
377    ///
378    /// This keeps all existing rate limits, adds new ones, and updates any existing ones
379    /// with a later expiration time. The resulting collection contains the maximum
380    /// constraints from both instances.
381    pub fn merge(&mut self, limits: Self) {
382        for limit in limits {
383            self.add(limit);
384        }
385    }
386
387    /// Merges all limits from another [`RateLimits`] with this one.
388    ///
389    /// See also: [`Self::merge`].
390    pub fn merge_with(mut self, other: Self) -> Self {
391        self.merge(other);
392        self
393    }
394
395    /// Returns `true` if this instance contains no active limits.
396    ///
397    /// This is the opposite of [`is_limited`](Self::is_limited).
398    pub fn is_ok(&self) -> bool {
399        !self.is_limited()
400    }
401
402    /// Returns `true` if this instance contains any active rate limits.
403    ///
404    /// A rate limit is considered active if it has not yet expired.
405    pub fn is_limited(&self) -> bool {
406        let now = Instant::now();
407        self.iter().any(|limit| !limit.retry_after.expired_at(now))
408    }
409
410    /// Returns `true` if this is instance contains any active rate limits
411    /// for the specified categories.
412    ///
413    /// A rate limit is considered active if it has not yet expired.
414    pub fn is_any_limited(&self, scopings: &[ItemScoping]) -> bool {
415        self.is_any_limited_with_quotas(&[], scopings)
416    }
417
418    /// Returns `true` if this is instance contains any active rate limits
419    /// for the specified categories.
420    ///
421    /// This is similar to [`Self::is_limited`], but additionally checks for quotas with a static
422    /// limit of `0`, which reject items even if there is no active rate limit in this instance.
423    ///
424    /// A rate limit is considered active if it has not yet expired.
425    pub fn is_any_limited_with_quotas<'a>(
426        &self,
427        quotas: impl IntoIterator<Item = &'a Quota>,
428        scopings: &[ItemScoping],
429    ) -> bool {
430        for quota in quotas {
431            for scoping in scopings {
432                if quota.limit == Some(0) && quota.matches(scoping) {
433                    return true;
434                }
435            }
436        }
437
438        let now = Instant::now();
439
440        scopings.iter().any(|scoping| {
441            self.matching(scoping)
442                .any(|limit| !limit.retry_after.expired_at(now))
443        })
444    }
445
446    /// Removes expired rate limits from this instance.
447    pub fn clean_expired(&mut self, now: Instant) {
448        while let Some(next) = self.expiry_heap.peek() {
449            if !next.retry_after.expired_at(now) {
450                break;
451            }
452
453            if let Some(limits) = self.limits.get_mut(&next.rl_key) {
454                limits.retain(|l| !l.retry_after.expired_at(now));
455                if limits.is_empty() {
456                    self.limits.remove(&next.rl_key);
457                }
458            }
459            self.expiry_heap.pop();
460        }
461    }
462
463    /// Returns `true` if any limit has expired at `now`.
464    pub fn has_expired(&self, now: Instant) -> bool {
465        self.expiry_heap
466            .peek()
467            .is_some_and(|next| next.retry_after.expired_at(now))
468    }
469
470    /// Returns every limit which applies to `scoping`.
471    fn matching<'a>(&'a self, scoping: &'a ItemScoping) -> impl Iterator<Item = &'a RateLimit> {
472        // Find all the matching dimension hashes of this item ("what RateLimit items have dimensions
473        // that could apply to this item?").  Do this by projecting the item's dimensions onto the
474        // dimension set of that RateLimit.
475        let matching_dimension_hashes = self
476            .dimension_sets
477            .iter()
478            .filter_map(|ds| scoping.dimensions.project(ds));
479
480        matching_dimension_hashes.flat_map(|hash| {
481            // Given a matching hash, compute the three RateLimitKeys we need to index into the
482            // limit map.  This is needed because an item could match any of RateLimitScope's
483            // enumerations: ProjectKey, ProjectId, or OrganizationId.  So, we fan out.
484            let rl_keys = [
485                RateLimitKey {
486                    scope: RateLimitScope::Key(scoping.project_key),
487                    dimension_hashes: hash,
488                },
489                RateLimitKey {
490                    scope: RateLimitScope::Organization(scoping.organization_id),
491                    dimension_hashes: hash,
492                },
493                RateLimitKey {
494                    scope: RateLimitScope::Project(scoping.project_id),
495                    dimension_hashes: hash,
496                },
497            ];
498
499            rl_keys
500                .into_iter()
501                .filter_map(|key| self.limits.get(&key))
502                .flatten()
503                .filter(|limit| limit.matches(scoping))
504        })
505    }
506
507    /// Returns only the set of rate limits that can be propagated upstream.  At this time,
508    /// we cannot propagate dimensioned limits, so these must be filtered out.
509    pub fn propagatable(&self) -> PropagatableRateLimits {
510        let mut propagatable = RateLimits::new();
511
512        for (k, limits) in self.limits.iter() {
513            if k.dimension_hashes == DimensionMap::EMPTY_MAP_HASH {
514                for limit in limits {
515                    // dimension_hashes is checked to see if this limit's dimensions _looks_ empty,
516                    // but we still should be paranoid about collisions, so one last check to be
517                    // sure.
518                    if limit.dimensions.is_empty() {
519                        propagatable.add(limit.clone());
520                    }
521                }
522            }
523        }
524
525        PropagatableRateLimits(propagatable)
526    }
527
528    /// Checks whether any rate limits apply to the given scoping.
529    ///
530    /// Returns a new [`RateLimits`] instance containing only the rate limits that match
531    /// the provided [`ItemScoping`]. If no limits match, the returned instance will be empty
532    /// and [`is_ok`](Self::is_ok) will return `true`.
533    pub fn check(&self, scoping: &ItemScoping) -> Self {
534        self.check_with_quotas(&[], scoping)
535    }
536
537    /// Checks whether any rate limits or static quotas apply to the given scoping.
538    ///
539    /// This is similar to [`check`](Self::check), but additionally checks for quotas with a static
540    /// limit of `0`, which reject items even if there is no active rate limit in this instance.
541    ///
542    /// Returns a new [`RateLimits`] instance containing the rate limits that match the provided
543    /// [`ItemScoping`]. If no limits or quotas match, the returned instance will be empty.
544    pub fn check_with_quotas<'a>(
545        &self,
546        quotas: impl IntoIterator<Item = &'a Quota>,
547        scoping: &ItemScoping,
548    ) -> Self {
549        let mut applied_limits = Self::new();
550
551        for quota in quotas {
552            if quota.limit == Some(0) && quota.matches(scoping) {
553                let retry_after = RetryAfter::from_secs(REJECT_ALL_SECS);
554                applied_limits.add(RateLimit::from_quota(quota, scoping, retry_after));
555            }
556        }
557
558        for limit in self.matching(scoping) {
559            applied_limits.add(limit.clone());
560        }
561
562        applied_limits
563    }
564
565    /// Returns an iterator over all rate limits in this collection.
566    pub fn iter(&self) -> RateLimitsIter<'_> {
567        RateLimitsIter {
568            iter: std::slice::Iter::default(),
569            dimensioned_iter: self.limits.values(),
570        }
571    }
572
573    /// Returns the rate limit with the latest expiration time.
574    ///
575    /// If multiple rate limits have the same expiration time, any of them may be returned.
576    /// If the collection is empty, returns `None`.
577    pub fn longest(&self) -> Option<&RateLimit> {
578        self.iter().max_by_key(|limit| limit.retry_after)
579    }
580
581    /// Returns `true` if there are no rate limits in this collection.
582    ///
583    /// Note that an empty collection is not the same as having no active limits.
584    /// Use [`is_ok`](Self::is_ok) to check if there are no active limits.
585    pub fn is_empty(&self) -> bool {
586        self.limits.is_empty()
587    }
588}
589
590/// An iterator over rate limit references.
591///
592/// This struct is created by the [`iter`](RateLimits::iter) method on [`RateLimits`].
593/// It yields shared references to the rate limits in the collection.
594pub struct RateLimitsIter<'a> {
595    iter: std::slice::Iter<'a, RateLimit>,
596    dimensioned_iter: std::collections::btree_map::Values<'a, RateLimitKey, Vec<RateLimit>>,
597}
598
599impl<'a> Iterator for RateLimitsIter<'a> {
600    type Item = &'a RateLimit;
601
602    fn next(&mut self) -> Option<Self::Item> {
603        loop {
604            if let Some(nxt) = self.iter.next() {
605                return Some(nxt);
606            }
607
608            self.iter = self.dimensioned_iter.next()?.iter();
609        }
610    }
611}
612
613impl IntoIterator for RateLimits {
614    type IntoIter = RateLimitsIntoIter;
615    type Item = RateLimit;
616
617    fn into_iter(self) -> Self::IntoIter {
618        let dimensioned_iter = self.limits.into_values();
619
620        RateLimitsIntoIter {
621            iter: std::vec::IntoIter::default(),
622            dimensioned_iter,
623        }
624    }
625}
626
627/// An iterator that consumes a [`RateLimits`] collection.
628///
629/// This struct is created by the `into_iter` method on [`RateLimits`], provided by the
630/// [`IntoIterator`] trait. It yields owned rate limits by value.
631pub struct RateLimitsIntoIter {
632    iter: std::vec::IntoIter<RateLimit>,
633    dimensioned_iter: std::collections::btree_map::IntoValues<RateLimitKey, Vec<RateLimit>>,
634}
635
636impl Iterator for RateLimitsIntoIter {
637    type Item = RateLimit;
638
639    fn next(&mut self) -> Option<Self::Item> {
640        loop {
641            let nxt = self.iter.next();
642            if nxt.is_some() {
643                return nxt;
644            }
645
646            self.iter = self.dimensioned_iter.next()?.into_iter();
647        }
648    }
649}
650
651impl<'a> IntoIterator for &'a RateLimits {
652    type IntoIter = RateLimitsIter<'a>;
653    type Item = &'a RateLimit;
654
655    fn into_iter(self) -> Self::IntoIter {
656        self.iter()
657    }
658}
659
660/// A thread-safe cache of rate limits with automatic expiration handling.
661///
662/// [`CachedRateLimits`] wraps a [`RateLimits`] collection with a mutex to allow safe
663/// concurrent access from multiple threads. It automatically removes expired rate limits
664/// when retrieving the current limits.
665///
666/// This is useful for maintaining a shared set of rate limits across multiple
667/// processing threads or tasks.
668#[derive(Debug, Default)]
669pub struct CachedRateLimits(Mutex<Arc<RateLimits>>);
670
671impl CachedRateLimits {
672    /// Creates a new, empty instance without any rate limits.
673    pub fn new() -> Self {
674        Self::default()
675    }
676
677    /// Adds a rate limit to this collection.
678    ///
679    /// This is a thread-safe wrapper around [`RateLimits::add`].
680    pub fn add(&self, limit: RateLimit) {
681        let mut inner = self.0.lock().unwrap_or_else(PoisonError::into_inner);
682        let current = Arc::make_mut(&mut inner);
683        current.add(limit);
684    }
685
686    /// Merges rate limits from another collection into this one.
687    ///
688    /// This is a thread-safe wrapper around [`RateLimits::merge`].
689    pub fn merge(&self, limits: RateLimits) {
690        if limits.is_empty() {
691            return;
692        }
693
694        let mut inner = self.0.lock().unwrap_or_else(PoisonError::into_inner);
695        let current = Arc::make_mut(&mut inner);
696        for mut limit in limits {
697            // To spice it up, we do have some special casing here for 'inherited categories',
698            // e.g. spans and transactions.
699            //
700            // The tldr; is, as transactions are just containers for spans,
701            // we can enforce span limits on transactions but also vice versa.
702            //
703            // So this is largely an enforcement problem, but since Relay propagates
704            // rate limits to clients, we clone the limits with the inherited category.
705            // This ensures old SDKs rate limit correctly, but also it simplifies client
706            // implementations. Only Relay needs to make this decision.
707            for category in limit.categories.iter() {
708                for inherited in inherited_categories(&category) {
709                    if let Some(categories) = limit.categories.add(*inherited) {
710                        limit.categories = categories;
711                    }
712                }
713            }
714
715            current.add(limit);
716        }
717    }
718
719    /// Returns a reference to the current rate limits.
720    ///
721    /// This method automatically removes any expired rate limits before returning,
722    /// ensuring that only active limits are included in the result.
723    pub fn current_limits(&self) -> Arc<RateLimits> {
724        let now = Instant::now();
725        let mut inner = self.0.lock().unwrap_or_else(PoisonError::into_inner);
726
727        // `Arc::make_mut` clones if there are other pointers to this arc, so only do make_mut if
728        // we need to.
729        if inner.has_expired(now) {
730            Arc::make_mut(&mut inner).clean_expired(now);
731        }
732        Arc::clone(&inner)
733    }
734}
735
736/// Returns inherited rate limit categories for the passed category.
737///
738/// When a rate limit for a category can also be enforced in a different category,
739/// then it's an inherited category.
740///
741/// For example, a transaction rate limit can also be applied to spans and vice versa.
742///
743/// For a detailed explanation on span/transaction enforcement see:
744/// <https://develop.sentry.dev/ingestion/relay/transaction-span-ratelimits/>.
745fn inherited_categories(category: &DataCategory) -> &'static [DataCategory] {
746    match category {
747        DataCategory::Transaction => &[DataCategory::Span],
748        DataCategory::Span => &[DataCategory::Transaction],
749        _ => &[],
750    }
751}
752
753#[cfg(test)]
754mod tests {
755
756    use smallvec::smallvec;
757
758    use super::*;
759    use crate::quota::DataCategory;
760    use crate::{DimensionMap, GroupBy, MetricNamespaceScoping};
761
762    #[test]
763    fn test_parse_retry_after() {
764        // positive float always rounds up to the next integer
765        let retry_after = "17.1".parse::<RetryAfter>().expect("parse RetryAfter");
766        assert_eq!(retry_after.remaining_seconds(), 18);
767        assert!(!retry_after.expired());
768        let retry_after = "17.7".parse::<RetryAfter>().expect("parse RetryAfter");
769        assert_eq!(retry_after.remaining_seconds(), 18);
770        assert!(!retry_after.expired());
771
772        // positive int
773        let retry_after = "17".parse::<RetryAfter>().expect("parse RetryAfter");
774        assert_eq!(retry_after.remaining_seconds(), 17);
775        assert!(!retry_after.expired());
776
777        // negative numbers are treated as zero
778        let retry_after = "-2".parse::<RetryAfter>().expect("parse RetryAfter");
779        assert_eq!(retry_after.remaining_seconds(), 0);
780        assert!(retry_after.expired());
781        let retry_after = "-inf".parse::<RetryAfter>().expect("parse RetryAfter");
782        assert_eq!(retry_after.remaining_seconds(), 0);
783        assert!(retry_after.expired());
784
785        // inf and NaN are valid input and treated as zero
786        let retry_after = "inf".parse::<RetryAfter>().expect("parse RetryAfter");
787        assert_eq!(retry_after.remaining_seconds(), 0);
788        assert!(retry_after.expired());
789        let retry_after = "NaN".parse::<RetryAfter>().expect("parse RetryAfter");
790        assert_eq!(retry_after.remaining_seconds(), 0);
791        assert!(retry_after.expired());
792
793        // large inputs that would overflow are treated as zero
794        let retry_after = "100000000000000000000"
795            .parse::<RetryAfter>()
796            .expect("parse RetryAfter");
797        assert_eq!(retry_after.remaining_seconds(), 0);
798        assert!(retry_after.expired());
799
800        // invalid strings cause parse error
801        "".parse::<RetryAfter>().expect_err("error RetryAfter");
802        "nope".parse::<RetryAfter>().expect_err("error RetryAfter");
803        " 2 ".parse::<RetryAfter>().expect_err("error RetryAfter");
804        "6 0".parse::<RetryAfter>().expect_err("error RetryAfter");
805    }
806
807    #[test]
808    fn test_rate_limit_matches_categories() {
809        let rate_limit = RateLimit {
810            categories: [DataCategory::Unknown, DataCategory::Error].into(),
811            scope: RateLimitScope::Organization(OrganizationId::new(42)),
812            reason_code: None,
813            retry_after: RetryAfter::from_secs(1),
814            namespaces: smallvec![],
815            dimensions: DimensionMap::default(),
816        };
817
818        assert!(rate_limit.matches(&ItemScoping {
819            category: DataCategory::Error,
820            scoping: Scoping {
821                organization_id: OrganizationId::new(42),
822                project_id: ProjectId::new(21),
823                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
824                key_id: None,
825            },
826            namespace: MetricNamespaceScoping::None,
827            dimensions: DimensionMap::default(),
828        }));
829
830        assert!(!rate_limit.matches(&ItemScoping {
831            category: DataCategory::Transaction,
832            scoping: Scoping {
833                organization_id: OrganizationId::new(42),
834                project_id: ProjectId::new(21),
835                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
836                key_id: None,
837            },
838            namespace: MetricNamespaceScoping::None,
839            dimensions: DimensionMap::default(),
840        }));
841    }
842
843    #[test]
844    fn test_rate_limit_matches_organization() {
845        let rate_limit = RateLimit {
846            categories: DataCategories::new(),
847            scope: RateLimitScope::Organization(OrganizationId::new(42)),
848            reason_code: None,
849            retry_after: RetryAfter::from_secs(1),
850            namespaces: smallvec![],
851            dimensions: DimensionMap::default(),
852        };
853
854        assert!(rate_limit.matches(&ItemScoping {
855            category: DataCategory::Error,
856            scoping: Scoping {
857                organization_id: OrganizationId::new(42),
858                project_id: ProjectId::new(21),
859                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
860                key_id: None,
861            },
862            namespace: MetricNamespaceScoping::None,
863            dimensions: DimensionMap::default(),
864        }));
865
866        assert!(!rate_limit.matches(&ItemScoping {
867            category: DataCategory::Error,
868            scoping: Scoping {
869                organization_id: OrganizationId::new(0),
870                project_id: ProjectId::new(21),
871                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
872                key_id: None,
873            },
874            namespace: MetricNamespaceScoping::None,
875            dimensions: DimensionMap::default(),
876        }));
877    }
878
879    #[test]
880    fn test_rate_limit_matches_project() {
881        let rate_limit = RateLimit {
882            categories: DataCategories::new(),
883            scope: RateLimitScope::Project(ProjectId::new(21)),
884            reason_code: None,
885            retry_after: RetryAfter::from_secs(1),
886            namespaces: smallvec![],
887            dimensions: DimensionMap::default(),
888        };
889
890        assert!(rate_limit.matches(&ItemScoping {
891            category: DataCategory::Error,
892            scoping: Scoping {
893                organization_id: OrganizationId::new(42),
894                project_id: ProjectId::new(21),
895                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
896                key_id: None,
897            },
898            namespace: MetricNamespaceScoping::None,
899            dimensions: DimensionMap::default(),
900        }));
901
902        assert!(!rate_limit.matches(&ItemScoping {
903            category: DataCategory::Error,
904            scoping: Scoping {
905                organization_id: OrganizationId::new(42),
906                project_id: ProjectId::new(0),
907                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
908                key_id: None,
909            },
910            namespace: MetricNamespaceScoping::None,
911            dimensions: DimensionMap::default(),
912        }));
913    }
914
915    #[test]
916    fn test_rate_limit_matches_namespaces() {
917        let rate_limit = RateLimit {
918            categories: Default::default(),
919            scope: RateLimitScope::Organization(OrganizationId::new(42)),
920            reason_code: None,
921            retry_after: RetryAfter::from_secs(1),
922            namespaces: smallvec![MetricNamespace::Transactions],
923            dimensions: DimensionMap::default(),
924        };
925
926        let scoping = Scoping {
927            organization_id: OrganizationId::new(42),
928            project_id: ProjectId::new(21),
929            project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
930            key_id: None,
931        };
932
933        assert!(rate_limit.matches(&ItemScoping {
934            category: DataCategory::MetricBucket,
935            scoping,
936            namespace: MetricNamespaceScoping::Some(MetricNamespace::Transactions),
937            dimensions: DimensionMap::default(),
938        }));
939
940        assert!(!rate_limit.matches(&ItemScoping {
941            category: DataCategory::MetricBucket,
942            scoping,
943            namespace: MetricNamespaceScoping::Some(MetricNamespace::Spans),
944            dimensions: DimensionMap::default(),
945        }));
946
947        let general_rate_limit = RateLimit {
948            categories: Default::default(),
949            scope: RateLimitScope::Organization(OrganizationId::new(42)),
950            reason_code: None,
951            retry_after: RetryAfter::from_secs(1),
952            namespaces: smallvec![], // all namespaces
953            dimensions: DimensionMap::default(),
954        };
955
956        assert!(general_rate_limit.matches(&ItemScoping {
957            category: DataCategory::MetricBucket,
958            scoping,
959            namespace: MetricNamespaceScoping::Some(MetricNamespace::Spans),
960            dimensions: DimensionMap::default(),
961        }));
962
963        assert!(general_rate_limit.matches(&ItemScoping {
964            category: DataCategory::MetricBucket,
965            scoping,
966            namespace: MetricNamespaceScoping::None,
967            dimensions: DimensionMap::default(),
968        }));
969    }
970
971    #[test]
972    fn test_rate_limit_matches_key() {
973        let rate_limit = RateLimit {
974            categories: DataCategories::new(),
975            scope: RateLimitScope::Key(
976                ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
977            ),
978            reason_code: None,
979            retry_after: RetryAfter::from_secs(1),
980            namespaces: smallvec![],
981            dimensions: DimensionMap::default(),
982        };
983
984        assert!(rate_limit.matches(&ItemScoping {
985            category: DataCategory::Error,
986            scoping: Scoping {
987                organization_id: OrganizationId::new(42),
988                project_id: ProjectId::new(21),
989                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
990                key_id: None,
991            },
992            namespace: MetricNamespaceScoping::None,
993            dimensions: DimensionMap::default(),
994        }));
995
996        assert!(!rate_limit.matches(&ItemScoping {
997            category: DataCategory::Error,
998            scoping: Scoping {
999                organization_id: OrganizationId::new(0),
1000                project_id: ProjectId::new(21),
1001                project_key: ProjectKey::parse("deadbeefdeadbeefdeadbeefdeadbeef").unwrap(),
1002                key_id: None,
1003            },
1004            namespace: MetricNamespaceScoping::None,
1005            dimensions: DimensionMap::default(),
1006        }));
1007    }
1008
1009    #[test]
1010    fn test_rate_limits_add_replacement() {
1011        let mut rate_limits = RateLimits::new();
1012
1013        rate_limits.add(RateLimit {
1014            categories: [DataCategory::Default, DataCategory::Error].into(),
1015            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1016            reason_code: Some(ReasonCode::new("first")),
1017            retry_after: RetryAfter::from_secs(1),
1018            namespaces: smallvec![],
1019            dimensions: DimensionMap::default(),
1020        });
1021
1022        // longer rate limit shadows shorter one
1023        rate_limits.add(RateLimit {
1024            categories: [DataCategory::Error, DataCategory::Default].into(),
1025            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1026            reason_code: Some(ReasonCode::new("second")),
1027            retry_after: RetryAfter::from_secs(10),
1028            namespaces: smallvec![],
1029            dimensions: DimensionMap::default(),
1030        });
1031
1032        insta::assert_ron_snapshot!(rate_limits, @r#"
1033        RateLimits(
1034          limits: {
1035            RateLimitKey(
1036              scope: Organization(OrganizationId(42)),
1037              dimension_hashes: 0,
1038            ): [
1039              RateLimit(
1040                categories: [
1041                  "default",
1042                  "error",
1043                ],
1044                scope: Organization(OrganizationId(42)),
1045                reason_code: Some(ReasonCode("second")),
1046                retry_after: RetryAfter(10),
1047                namespaces: [],
1048                dimensions: DimensionMap(
1049                  dimensions: {},
1050                  hash: 0,
1051                ),
1052              ),
1053            ],
1054          },
1055          dimension_sets: [
1056            [],
1057          ],
1058          expiry_heap: [
1059            HeapItem(
1060              retry_after: RetryAfter(1),
1061              rl_key: RateLimitKey(
1062                scope: Organization(OrganizationId(42)),
1063                dimension_hashes: 0,
1064              ),
1065            ),
1066            HeapItem(
1067              retry_after: RetryAfter(10),
1068              rl_key: RateLimitKey(
1069                scope: Organization(OrganizationId(42)),
1070                dimension_hashes: 0,
1071              ),
1072            ),
1073          ],
1074        )
1075        "#);
1076    }
1077
1078    #[test]
1079    fn test_rate_limits_add_shadowing() {
1080        let mut rate_limits = RateLimits::new();
1081
1082        rate_limits.add(RateLimit {
1083            categories: [DataCategory::Default, DataCategory::Error].into(),
1084            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1085            reason_code: Some(ReasonCode::new("first")),
1086            retry_after: RetryAfter::from_secs(10),
1087            namespaces: smallvec![],
1088            dimensions: DimensionMap::default(),
1089        });
1090
1091        // shorter rate limit is shadowed by existing one
1092        rate_limits.add(RateLimit {
1093            categories: [DataCategory::Error, DataCategory::Default].into(),
1094            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1095            reason_code: Some(ReasonCode::new("second")),
1096            retry_after: RetryAfter::from_secs(1),
1097            namespaces: smallvec![],
1098            dimensions: DimensionMap::default(),
1099        });
1100
1101        insta::assert_ron_snapshot!(rate_limits, @r#"
1102        RateLimits(
1103          limits: {
1104            RateLimitKey(
1105              scope: Organization(OrganizationId(42)),
1106              dimension_hashes: 0,
1107            ): [
1108              RateLimit(
1109                categories: [
1110                  "default",
1111                  "error",
1112                ],
1113                scope: Organization(OrganizationId(42)),
1114                reason_code: Some(ReasonCode("first")),
1115                retry_after: RetryAfter(10),
1116                namespaces: [],
1117                dimensions: DimensionMap(
1118                  dimensions: {},
1119                  hash: 0,
1120                ),
1121              ),
1122            ],
1123          },
1124          dimension_sets: [
1125            [],
1126          ],
1127          expiry_heap: [
1128            HeapItem(
1129              retry_after: RetryAfter(10),
1130              rl_key: RateLimitKey(
1131                scope: Organization(OrganizationId(42)),
1132                dimension_hashes: 0,
1133              ),
1134            ),
1135          ],
1136        )
1137        "#);
1138    }
1139
1140    #[test]
1141    fn test_rate_limits_add_buckets() {
1142        let mut rate_limits = RateLimits::new();
1143
1144        rate_limits.add(RateLimit {
1145            categories: [DataCategory::Error].into(),
1146            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1147            reason_code: None,
1148            retry_after: RetryAfter::from_secs(1),
1149            namespaces: smallvec![],
1150            dimensions: DimensionMap::default(),
1151        });
1152
1153        // Same scope but different categories
1154        rate_limits.add(RateLimit {
1155            categories: [DataCategory::Transaction].into(),
1156            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1157            reason_code: None,
1158            retry_after: RetryAfter::from_secs(1),
1159            namespaces: smallvec![],
1160            dimensions: DimensionMap::default(),
1161        });
1162
1163        // Same categories but different scope
1164        rate_limits.add(RateLimit {
1165            categories: [DataCategory::Error].into(),
1166            scope: RateLimitScope::Project(ProjectId::new(21)),
1167            reason_code: None,
1168            retry_after: RetryAfter::from_secs(1),
1169            namespaces: smallvec![],
1170            dimensions: DimensionMap::default(),
1171        });
1172
1173        insta::assert_ron_snapshot!(rate_limits, @r#"
1174        RateLimits(
1175          limits: {
1176            RateLimitKey(
1177              scope: Organization(OrganizationId(42)),
1178              dimension_hashes: 0,
1179            ): [
1180              RateLimit(
1181                categories: [
1182                  "error",
1183                ],
1184                scope: Organization(OrganizationId(42)),
1185                reason_code: None,
1186                retry_after: RetryAfter(1),
1187                namespaces: [],
1188                dimensions: DimensionMap(
1189                  dimensions: {},
1190                  hash: 0,
1191                ),
1192              ),
1193              RateLimit(
1194                categories: [
1195                  "transaction",
1196                ],
1197                scope: Organization(OrganizationId(42)),
1198                reason_code: None,
1199                retry_after: RetryAfter(1),
1200                namespaces: [],
1201                dimensions: DimensionMap(
1202                  dimensions: {},
1203                  hash: 0,
1204                ),
1205              ),
1206            ],
1207            RateLimitKey(
1208              scope: Project(ProjectId(21)),
1209              dimension_hashes: 0,
1210            ): [
1211              RateLimit(
1212                categories: [
1213                  "error",
1214                ],
1215                scope: Project(ProjectId(21)),
1216                reason_code: None,
1217                retry_after: RetryAfter(1),
1218                namespaces: [],
1219                dimensions: DimensionMap(
1220                  dimensions: {},
1221                  hash: 0,
1222                ),
1223              ),
1224            ],
1225          },
1226          dimension_sets: [
1227            [],
1228          ],
1229          expiry_heap: [
1230            HeapItem(
1231              retry_after: RetryAfter(1),
1232              rl_key: RateLimitKey(
1233                scope: Organization(OrganizationId(42)),
1234                dimension_hashes: 0,
1235              ),
1236            ),
1237            HeapItem(
1238              retry_after: RetryAfter(1),
1239              rl_key: RateLimitKey(
1240                scope: Organization(OrganizationId(42)),
1241                dimension_hashes: 0,
1242              ),
1243            ),
1244            HeapItem(
1245              retry_after: RetryAfter(1),
1246              rl_key: RateLimitKey(
1247                scope: Project(ProjectId(21)),
1248                dimension_hashes: 0,
1249              ),
1250            ),
1251          ],
1252        )
1253        "#);
1254    }
1255
1256    /// Regression test that ensures namespaces are correctly added to rate limits.
1257    #[test]
1258    fn test_rate_limits_add_namespaces() {
1259        let mut rate_limits = RateLimits::new();
1260
1261        rate_limits.add(RateLimit {
1262            categories: [DataCategory::MetricBucket].into(),
1263            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1264            reason_code: None,
1265            retry_after: RetryAfter::from_secs(1),
1266            namespaces: smallvec![MetricNamespace::Transactions],
1267            dimensions: DimensionMap::default(),
1268        });
1269
1270        // Same category but different namespaces
1271        rate_limits.add(RateLimit {
1272            categories: [DataCategory::MetricBucket].into(),
1273            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1274            reason_code: None,
1275            retry_after: RetryAfter::from_secs(1),
1276            namespaces: smallvec![MetricNamespace::Spans],
1277            dimensions: DimensionMap::default(),
1278        });
1279
1280        insta::assert_ron_snapshot!(rate_limits, @r#"
1281        RateLimits(
1282          limits: {
1283            RateLimitKey(
1284              scope: Organization(OrganizationId(42)),
1285              dimension_hashes: 0,
1286            ): [
1287              RateLimit(
1288                categories: [
1289                  "metric_bucket",
1290                ],
1291                scope: Organization(OrganizationId(42)),
1292                reason_code: None,
1293                retry_after: RetryAfter(1),
1294                namespaces: [
1295                  "transactions",
1296                ],
1297                dimensions: DimensionMap(
1298                  dimensions: {},
1299                  hash: 0,
1300                ),
1301              ),
1302              RateLimit(
1303                categories: [
1304                  "metric_bucket",
1305                ],
1306                scope: Organization(OrganizationId(42)),
1307                reason_code: None,
1308                retry_after: RetryAfter(1),
1309                namespaces: [
1310                  "spans",
1311                ],
1312                dimensions: DimensionMap(
1313                  dimensions: {},
1314                  hash: 0,
1315                ),
1316              ),
1317            ],
1318          },
1319          dimension_sets: [
1320            [],
1321          ],
1322          expiry_heap: [
1323            HeapItem(
1324              retry_after: RetryAfter(1),
1325              rl_key: RateLimitKey(
1326                scope: Organization(OrganizationId(42)),
1327                dimension_hashes: 0,
1328              ),
1329            ),
1330            HeapItem(
1331              retry_after: RetryAfter(1),
1332              rl_key: RateLimitKey(
1333                scope: Organization(OrganizationId(42)),
1334                dimension_hashes: 0,
1335              ),
1336            ),
1337          ],
1338        )
1339        "#);
1340    }
1341
1342    #[test]
1343    fn test_rate_limits_longest() {
1344        let mut rate_limits = RateLimits::new();
1345
1346        rate_limits.add(RateLimit {
1347            categories: [DataCategory::Error].into(),
1348            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1349            reason_code: Some(ReasonCode::new("first")),
1350            retry_after: RetryAfter::from_secs(1),
1351            namespaces: smallvec![],
1352            dimensions: DimensionMap::default(),
1353        });
1354
1355        // Distinct scope to prevent deduplication
1356        rate_limits.add(RateLimit {
1357            categories: [DataCategory::Transaction].into(),
1358            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1359            reason_code: Some(ReasonCode::new("second")),
1360            retry_after: RetryAfter::from_secs(10),
1361            namespaces: smallvec![],
1362            dimensions: DimensionMap::default(),
1363        });
1364
1365        let rate_limit = rate_limits.longest().unwrap();
1366        insta::assert_ron_snapshot!(rate_limit, @r#"
1367        RateLimit(
1368          categories: [
1369            "transaction",
1370          ],
1371          scope: Organization(OrganizationId(42)),
1372          reason_code: Some(ReasonCode("second")),
1373          retry_after: RetryAfter(10),
1374          namespaces: [],
1375          dimensions: DimensionMap(
1376            dimensions: {},
1377            hash: 0,
1378          ),
1379        )
1380        "#);
1381    }
1382
1383    #[test]
1384    fn test_rate_limits_clean_expired() {
1385        let mut rate_limits = RateLimits::new();
1386
1387        // Active error limit
1388        rate_limits.add(RateLimit {
1389            categories: [DataCategory::Error].into(),
1390            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1391            reason_code: None,
1392            retry_after: RetryAfter::from_secs(1),
1393            namespaces: smallvec![],
1394            dimensions: DimensionMap::default(),
1395        });
1396
1397        // Inactive error limit with distinct scope
1398        rate_limits.add(RateLimit {
1399            categories: [DataCategory::Error].into(),
1400            scope: RateLimitScope::Project(ProjectId::new(21)),
1401            reason_code: None,
1402            retry_after: RetryAfter::from_secs(0),
1403            namespaces: smallvec![],
1404            dimensions: DimensionMap::default(),
1405        });
1406
1407        // Sanity check before running `clean_expired`
1408        assert_eq!(rate_limits.iter().count(), 2);
1409
1410        rate_limits.clean_expired(Instant::now());
1411
1412        // Check that the expired limit has been removed
1413        insta::assert_ron_snapshot!(rate_limits, @r#"
1414        RateLimits(
1415          limits: {
1416            RateLimitKey(
1417              scope: Organization(OrganizationId(42)),
1418              dimension_hashes: 0,
1419            ): [
1420              RateLimit(
1421                categories: [
1422                  "error",
1423                ],
1424                scope: Organization(OrganizationId(42)),
1425                reason_code: None,
1426                retry_after: RetryAfter(1),
1427                namespaces: [],
1428                dimensions: DimensionMap(
1429                  dimensions: {},
1430                  hash: 0,
1431                ),
1432              ),
1433            ],
1434          },
1435          dimension_sets: [
1436            [],
1437          ],
1438          expiry_heap: [
1439            HeapItem(
1440              retry_after: RetryAfter(1),
1441              rl_key: RateLimitKey(
1442                scope: Organization(OrganizationId(42)),
1443                dimension_hashes: 0,
1444              ),
1445            ),
1446          ],
1447        )
1448        "#);
1449    }
1450
1451    #[test]
1452    fn test_rate_limits_is_any_limited() {
1453        let mut rate_limits = RateLimits::new();
1454
1455        // Active error limit
1456        rate_limits.add(RateLimit {
1457            categories: [DataCategory::Error].into(),
1458            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1459            reason_code: None,
1460            retry_after: RetryAfter::from_secs(1),
1461            namespaces: smallvec![],
1462            dimensions: DimensionMap::default(),
1463        });
1464
1465        // Active transaction limit
1466        rate_limits.add(RateLimit {
1467            categories: [DataCategory::Transaction].into(),
1468            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1469            reason_code: None,
1470            retry_after: RetryAfter::from_secs(1),
1471            namespaces: smallvec![],
1472            dimensions: DimensionMap::default(),
1473        });
1474
1475        let scoping = Scoping {
1476            organization_id: OrganizationId::new(42),
1477            project_id: ProjectId::new(21),
1478            project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1479            key_id: None,
1480        };
1481
1482        let quotas = &[Quota {
1483            id: None,
1484            categories: [DataCategory::Attachment].into(),
1485            scope: QuotaScope::Organization,
1486            scope_id: Some("42".into()),
1487            limit: Some(0),
1488            window: None,
1489            reason_code: Some(ReasonCode::new("zero")),
1490            namespace: None,
1491            group_by: None,
1492        }];
1493
1494        assert!(
1495            rate_limits.is_any_limited_with_quotas(quotas, &[scoping.item(DataCategory::Error)])
1496        );
1497        assert!(
1498            rate_limits
1499                .is_any_limited_with_quotas(quotas, &[scoping.item(DataCategory::Attachment)])
1500        );
1501        assert!(rate_limits.is_any_limited_with_quotas(
1502            quotas,
1503            &[
1504                scoping.item(DataCategory::Replay),
1505                scoping.item(DataCategory::Error)
1506            ]
1507        ));
1508
1509        assert!(
1510            !rate_limits.is_any_limited_with_quotas(quotas, &[scoping.item(DataCategory::Replay)])
1511        );
1512    }
1513
1514    #[test]
1515    fn test_rate_limits_check() {
1516        let mut rate_limits = RateLimits::new();
1517
1518        // Active error limit
1519        rate_limits.add(RateLimit {
1520            categories: [DataCategory::Error].into(),
1521            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1522            reason_code: None,
1523            retry_after: RetryAfter::from_secs(1),
1524            namespaces: smallvec![],
1525            dimensions: DimensionMap::default(),
1526        });
1527
1528        // Active transaction limit
1529        rate_limits.add(RateLimit {
1530            categories: [DataCategory::Transaction].into(),
1531            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1532            reason_code: None,
1533            retry_after: RetryAfter::from_secs(1),
1534            namespaces: smallvec![],
1535            dimensions: DimensionMap::default(),
1536        });
1537
1538        let applied_limits = rate_limits.check(&ItemScoping {
1539            category: DataCategory::Error,
1540            scoping: Scoping {
1541                organization_id: OrganizationId::new(42),
1542                project_id: ProjectId::new(21),
1543                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1544                key_id: None,
1545            },
1546            namespace: MetricNamespaceScoping::None,
1547            dimensions: DimensionMap::default(),
1548        });
1549
1550        // Check that the error limit is applied
1551        insta::assert_ron_snapshot!(applied_limits, @r#"
1552        RateLimits(
1553          limits: {
1554            RateLimitKey(
1555              scope: Organization(OrganizationId(42)),
1556              dimension_hashes: 0,
1557            ): [
1558              RateLimit(
1559                categories: [
1560                  "error",
1561                ],
1562                scope: Organization(OrganizationId(42)),
1563                reason_code: None,
1564                retry_after: RetryAfter(1),
1565                namespaces: [],
1566                dimensions: DimensionMap(
1567                  dimensions: {},
1568                  hash: 0,
1569                ),
1570              ),
1571            ],
1572          },
1573          dimension_sets: [
1574            [],
1575          ],
1576          expiry_heap: [
1577            HeapItem(
1578              retry_after: RetryAfter(1),
1579              rl_key: RateLimitKey(
1580                scope: Organization(OrganizationId(42)),
1581                dimension_hashes: 0,
1582              ),
1583            ),
1584          ],
1585        )
1586        "#);
1587    }
1588
1589    #[test]
1590    fn test_rate_limits_check_quotas() {
1591        let mut rate_limits = RateLimits::new();
1592
1593        // Active error limit
1594        rate_limits.add(RateLimit {
1595            categories: [DataCategory::Error].into(),
1596            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1597            reason_code: None,
1598            retry_after: RetryAfter::from_secs(1),
1599            namespaces: smallvec![],
1600            dimensions: DimensionMap::default(),
1601        });
1602
1603        // Active transaction limit
1604        rate_limits.add(RateLimit {
1605            categories: [DataCategory::Transaction].into(),
1606            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1607            reason_code: None,
1608            retry_after: RetryAfter::from_secs(1),
1609            namespaces: smallvec![],
1610            dimensions: DimensionMap::default(),
1611        });
1612
1613        let item_scoping = ItemScoping {
1614            category: DataCategory::Error,
1615            scoping: Scoping {
1616                organization_id: OrganizationId::new(42),
1617                project_id: ProjectId::new(21),
1618                project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1619                key_id: None,
1620            },
1621            namespace: MetricNamespaceScoping::None,
1622            dimensions: DimensionMap::default(),
1623        };
1624
1625        let quotas = &[Quota {
1626            id: None,
1627            categories: [DataCategory::Error].into(),
1628            scope: QuotaScope::Organization,
1629            scope_id: Some("42".into()),
1630            limit: Some(0),
1631            window: None,
1632            reason_code: Some(ReasonCode::new("zero")),
1633            namespace: None,
1634            group_by: None,
1635        }];
1636
1637        let applied_limits = rate_limits.check_with_quotas(quotas, &item_scoping);
1638
1639        insta::assert_ron_snapshot!(applied_limits, @r#"
1640        RateLimits(
1641          limits: {
1642            RateLimitKey(
1643              scope: Organization(OrganizationId(42)),
1644              dimension_hashes: 0,
1645            ): [
1646              RateLimit(
1647                categories: [
1648                  "error",
1649                ],
1650                scope: Organization(OrganizationId(42)),
1651                reason_code: Some(ReasonCode("zero")),
1652                retry_after: RetryAfter(60),
1653                namespaces: [],
1654                dimensions: DimensionMap(
1655                  dimensions: {},
1656                  hash: 0,
1657                ),
1658              ),
1659            ],
1660          },
1661          dimension_sets: [
1662            [],
1663          ],
1664          expiry_heap: [
1665            HeapItem(
1666              retry_after: RetryAfter(60),
1667              rl_key: RateLimitKey(
1668                scope: Organization(OrganizationId(42)),
1669                dimension_hashes: 0,
1670              ),
1671            ),
1672          ],
1673        )
1674        "#);
1675    }
1676
1677    #[test]
1678    fn test_rate_limits_merge() {
1679        let mut rate_limits1 = RateLimits::new();
1680        let mut rate_limits2 = RateLimits::new();
1681
1682        rate_limits1.add(RateLimit {
1683            categories: [DataCategory::Error].into(),
1684            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1685            reason_code: Some(ReasonCode::new("first")),
1686            retry_after: RetryAfter::from_secs(1),
1687            namespaces: smallvec![],
1688            dimensions: DimensionMap::default(),
1689        });
1690
1691        rate_limits1.add(RateLimit {
1692            categories: [DataCategory::TransactionIndexed].into(),
1693            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1694            reason_code: None,
1695            retry_after: RetryAfter::from_secs(1),
1696            namespaces: smallvec![],
1697            dimensions: DimensionMap::default(),
1698        });
1699
1700        rate_limits2.add(RateLimit {
1701            categories: [DataCategory::Error].into(),
1702            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1703            reason_code: Some(ReasonCode::new("second")),
1704            retry_after: RetryAfter::from_secs(10),
1705            namespaces: smallvec![],
1706            dimensions: DimensionMap::default(),
1707        });
1708
1709        rate_limits1.merge(rate_limits2);
1710
1711        insta::assert_ron_snapshot!(rate_limits1, @r#"
1712        RateLimits(
1713          limits: {
1714            RateLimitKey(
1715              scope: Organization(OrganizationId(42)),
1716              dimension_hashes: 0,
1717            ): [
1718              RateLimit(
1719                categories: [
1720                  "error",
1721                ],
1722                scope: Organization(OrganizationId(42)),
1723                reason_code: Some(ReasonCode("second")),
1724                retry_after: RetryAfter(10),
1725                namespaces: [],
1726                dimensions: DimensionMap(
1727                  dimensions: {},
1728                  hash: 0,
1729                ),
1730              ),
1731              RateLimit(
1732                categories: [
1733                  "transaction_indexed",
1734                ],
1735                scope: Organization(OrganizationId(42)),
1736                reason_code: None,
1737                retry_after: RetryAfter(1),
1738                namespaces: [],
1739                dimensions: DimensionMap(
1740                  dimensions: {},
1741                  hash: 0,
1742                ),
1743              ),
1744            ],
1745          },
1746          dimension_sets: [
1747            [],
1748          ],
1749          expiry_heap: [
1750            HeapItem(
1751              retry_after: RetryAfter(1),
1752              rl_key: RateLimitKey(
1753                scope: Organization(OrganizationId(42)),
1754                dimension_hashes: 0,
1755              ),
1756            ),
1757            HeapItem(
1758              retry_after: RetryAfter(1),
1759              rl_key: RateLimitKey(
1760                scope: Organization(OrganizationId(42)),
1761                dimension_hashes: 0,
1762              ),
1763            ),
1764            HeapItem(
1765              retry_after: RetryAfter(10),
1766              rl_key: RateLimitKey(
1767                scope: Organization(OrganizationId(42)),
1768                dimension_hashes: 0,
1769              ),
1770            ),
1771          ],
1772        )
1773        "#);
1774    }
1775
1776    #[test]
1777    fn test_cached_rate_limits_expired() {
1778        let cached = CachedRateLimits::new();
1779
1780        // Active error limit
1781        cached.add(RateLimit {
1782            categories: [DataCategory::Error].into(),
1783            scope: RateLimitScope::Organization(OrganizationId::new(42)),
1784            reason_code: None,
1785            retry_after: RetryAfter::from_secs(1),
1786            namespaces: smallvec![],
1787            dimensions: DimensionMap::default(),
1788        });
1789
1790        // Inactive error limit with distinct scope
1791        cached.add(RateLimit {
1792            categories: [DataCategory::Error].into(),
1793            scope: RateLimitScope::Project(ProjectId::new(21)),
1794            reason_code: None,
1795            retry_after: RetryAfter::from_secs(0),
1796            namespaces: smallvec![],
1797            dimensions: DimensionMap::default(),
1798        });
1799
1800        let rate_limits = cached.current_limits();
1801
1802        insta::assert_ron_snapshot!(rate_limits, @r#"
1803        RateLimits(
1804          limits: {
1805            RateLimitKey(
1806              scope: Organization(OrganizationId(42)),
1807              dimension_hashes: 0,
1808            ): [
1809              RateLimit(
1810                categories: [
1811                  "error",
1812                ],
1813                scope: Organization(OrganizationId(42)),
1814                reason_code: None,
1815                retry_after: RetryAfter(1),
1816                namespaces: [],
1817                dimensions: DimensionMap(
1818                  dimensions: {},
1819                  hash: 0,
1820                ),
1821              ),
1822            ],
1823          },
1824          dimension_sets: [
1825            [],
1826          ],
1827          expiry_heap: [
1828            HeapItem(
1829              retry_after: RetryAfter(1),
1830              rl_key: RateLimitKey(
1831                scope: Organization(OrganizationId(42)),
1832                dimension_hashes: 0,
1833              ),
1834            ),
1835          ],
1836        )
1837        "#);
1838    }
1839
1840    fn monitor_quota(group_by: Option<GroupBy>) -> Quota {
1841        Quota {
1842            id: Some("quota".into()),
1843            categories: [DataCategory::Monitor].into(),
1844            scope: QuotaScope::Project,
1845            scope_id: None,
1846            limit: Some(6),
1847            window: Some(60),
1848            reason_code: Some(ReasonCode::new("monitor_limit")),
1849            namespace: None,
1850            group_by,
1851        }
1852    }
1853
1854    fn group_by_slug() -> Option<GroupBy> {
1855        Some(GroupBy {
1856            max_cardinality: 999,
1857            dimensions: std::collections::BTreeSet::from([crate::Dimension::CheckInSlug]).into(),
1858        })
1859    }
1860
1861    fn monitor_scoping(slug: Option<&str>) -> ItemScoping {
1862        let scoping = Scoping {
1863            organization_id: OrganizationId::new(42),
1864            project_id: ProjectId::new(43),
1865            project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1866            key_id: Some(44),
1867        };
1868
1869        match slug {
1870            Some(slug) => scoping.item_with_dimensions(
1871                DataCategory::Monitor,
1872                DimensionMap::from([(crate::Dimension::CheckInSlug, slug.to_owned())]),
1873            ),
1874            None => scoping.item(DataCategory::Monitor),
1875        }
1876    }
1877
1878    /// A limit inherits the dimensionality of the quota which produced it.
1879    #[test]
1880    fn test_rate_limit_from_quota_marks_dimensional() {
1881        let scoping = monitor_scoping(Some("cron1"));
1882        let retry_after = RetryAfter::from_secs(60);
1883
1884        let plain = RateLimit::from_quota(&monitor_quota(None), &scoping, retry_after);
1885        assert!(plain.dimensions.is_empty());
1886
1887        let dimensional =
1888            RateLimit::from_quota(&monitor_quota(group_by_slug()), &scoping, retry_after);
1889        assert!(!dimensional.dimensions.is_empty());
1890    }
1891
1892    /// A dimensional limit matches every item in its scope, which is exactly why it must not be
1893    /// cached. `cacheable` is what keeps it out of the cache.
1894    #[test]
1895    fn test_rate_limits_cacheable_drops_dimensional() {
1896        let scoping = monitor_scoping(Some("cron1"));
1897        let retry_after = RetryAfter::from_secs(60);
1898
1899        let mut limits = RateLimits::new();
1900        limits.add(RateLimit::from_quota(
1901            &monitor_quota(group_by_slug()),
1902            &scoping,
1903            retry_after,
1904        ));
1905
1906        // The limit is correctly enforced against the item which produced it, but it also
1907        // matches an unrelated monitor in the same project, and an item with no dimensions.
1908        assert!(limits.check(&monitor_scoping(Some("cron1"))).is_limited());
1909        assert!(!limits.check(&monitor_scoping(Some("cron2"))).is_limited());
1910        assert!(!limits.check(&monitor_scoping(None)).is_limited());
1911    }
1912
1913    /// Filtering keeps the scope-wide limits, so a project which exhausts its total monitor
1914    /// quota still backs off.
1915    #[test]
1916    fn test_rate_limits_cacheable_keeps_plain() {
1917        let scoping = monitor_scoping(Some("cron1"));
1918        let retry_after = RetryAfter::from_secs(60);
1919
1920        let mut limits = RateLimits::new();
1921        limits.add(RateLimit::from_quota(
1922            &monitor_quota(group_by_slug()),
1923            &scoping,
1924            retry_after,
1925        ));
1926        limits.add(RateLimit::from_quota(
1927            &monitor_quota(None),
1928            &scoping,
1929            retry_after,
1930        ));
1931
1932        // Both limits are kept apart despite sharing scope, categories and namespaces.
1933        assert_eq!(limits.limits.len(), 2);
1934
1935        assert!(limits.check(&monitor_scoping(Some("cron2"))).is_limited());
1936    }
1937
1938    /// A dimensional limit must not displace a plain one when both are added.
1939    #[test]
1940    fn test_rate_limits_add_keeps_dimensional_separate() {
1941        let scoping = monitor_scoping(Some("cron1"));
1942
1943        let mut limits = RateLimits::new();
1944        // The dimensional limit is the longer of the two, so without the `dimensional` field in
1945        // the merge key it would overwrite the plain one and be cached in its place.
1946        limits.add(RateLimit::from_quota(
1947            &monitor_quota(None),
1948            &scoping,
1949            RetryAfter::from_secs(60),
1950        ));
1951        limits.add(RateLimit::from_quota(
1952            &monitor_quota(group_by_slug()),
1953            &scoping,
1954            RetryAfter::from_secs(600),
1955        ));
1956
1957        assert_eq!(limits.limits.len(), 2);
1958    }
1959
1960    fn group_by(dimensions: impl IntoIterator<Item = Dimension>) -> Option<GroupBy> {
1961        Some(GroupBy {
1962            max_cardinality: 999,
1963            dimensions: BTreeSet::from_iter(dimensions).into(),
1964        })
1965    }
1966
1967    fn check_in(environment: &str, slug: &str) -> ItemScoping {
1968        Scoping {
1969            organization_id: OrganizationId::new(42),
1970            project_id: ProjectId::new(43),
1971            project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1972            key_id: Some(44),
1973        }
1974        .item_with_dimensions(
1975            DataCategory::Monitor,
1976            DimensionMap::from([
1977                (Dimension::CheckInEnvironment, environment),
1978                (Dimension::CheckInSlug, slug),
1979            ]),
1980        )
1981    }
1982
1983    fn limit_for(quota: &Quota, scoping: &ItemScoping) -> RateLimit {
1984        RateLimit::from_quota(quota, scoping, RetryAfter::from_secs(60))
1985    }
1986
1987    #[test]
1988    fn test_cached_limit_ignores_unrelated_dimensions() {
1989        let quota = monitor_quota(group_by([Dimension::CheckInSlug]));
1990
1991        let prod = check_in("prod", "cron1");
1992        let dev = check_in("dev", "cron1");
1993
1994        let mut limits = RateLimits::new();
1995        limits.add(limit_for(&quota, &prod));
1996
1997        assert!(limits.check(&prod).is_limited());
1998        assert!(limits.check(&dev).is_limited());
1999    }
2000
2001    #[test]
2002    fn test_cached_limit_separates_distinct_dimensions() {
2003        let quota = monitor_quota(group_by([Dimension::CheckInSlug]));
2004
2005        let mut limits = RateLimits::new();
2006        limits.add(limit_for(&quota, &check_in("prod", "cron1")));
2007
2008        assert!(limits.check(&check_in("prod", "cron1")).is_limited());
2009        assert!(!limits.check(&check_in("prod", "cron2")).is_limited());
2010    }
2011
2012    #[test]
2013    fn test_cached_limit_matches_all_dimensions() {
2014        let quota = monitor_quota(group_by([
2015            Dimension::CheckInEnvironment,
2016            Dimension::CheckInSlug,
2017        ]));
2018
2019        let mut limits = RateLimits::new();
2020        limits.add(limit_for(&quota, &check_in("prod", "cron1")));
2021
2022        assert!(limits.check(&check_in("prod", "cron1")).is_limited());
2023        assert!(!limits.check(&check_in("dev", "cron1")).is_limited());
2024        assert!(!limits.check(&check_in("prod", "cron2")).is_limited());
2025    }
2026
2027    #[test]
2028    fn test_cached_limit_skips_undimensioned_item() {
2029        let quota = monitor_quota(group_by([Dimension::CheckInSlug]));
2030
2031        let mut limits = RateLimits::new();
2032        limits.add(limit_for(&quota, &check_in("prod", "cron1")));
2033
2034        assert!(!limits.check(&monitor_scoping(None)).is_limited());
2035    }
2036
2037    #[test]
2038    fn test_cached_undimensioned_limit_applies_to_all() {
2039        let quota = monitor_quota(None);
2040
2041        let mut limits = RateLimits::new();
2042        limits.add(limit_for(&quota, &check_in("prod", "cron1")));
2043
2044        assert!(limits.check(&check_in("prod", "cron1")).is_limited());
2045        assert!(limits.check(&check_in("dev", "cron2")).is_limited());
2046        assert!(limits.check(&monitor_scoping(None)).is_limited());
2047    }
2048
2049    #[test]
2050    fn test_cached_limits_mix_dimensioned_and_plain() {
2051        let mut limits = RateLimits::new();
2052        limits.add(limit_for(
2053            &monitor_quota(group_by([Dimension::CheckInSlug])),
2054            &check_in("prod", "cron1"),
2055        ));
2056        limits.add(limit_for(&monitor_quota(None), &check_in("prod", "cron1")));
2057
2058        assert_eq!(limits.check(&check_in("prod", "cron1")).iter().count(), 2);
2059        assert_eq!(limits.check(&check_in("prod", "cron2")).iter().count(), 1);
2060    }
2061
2062    #[test]
2063    fn test_is_any_limited_respects_dimensions() {
2064        let quota = monitor_quota(group_by([Dimension::CheckInSlug]));
2065
2066        let mut limits = RateLimits::new();
2067        limits.add(limit_for(&quota, &check_in("prod", "cron1")));
2068
2069        assert!(limits.is_any_limited(&[check_in("dev", "cron1")]));
2070        assert!(!limits.is_any_limited(&[check_in("prod", "cron2")]));
2071    }
2072
2073    #[test]
2074    fn test_propagatable_is_empty_without_plain_limits() {
2075        assert!(RateLimits::new().propagatable().is_empty());
2076
2077        let mut limits = RateLimits::new();
2078        limits.add(limit_for(
2079            &monitor_quota(group_by([Dimension::CheckInSlug])),
2080            &check_in("prod", "cron1"),
2081        ));
2082
2083        let propagatable = limits.propagatable();
2084        assert!(propagatable.is_empty());
2085        assert!(!propagatable.is_limited());
2086        assert!(propagatable.longest().is_none());
2087    }
2088
2089    #[test]
2090    fn test_propagatable_keeps_plain_limits() {
2091        let mut limits = RateLimits::new();
2092        limits.add(limit_for(
2093            &monitor_quota(group_by([Dimension::CheckInSlug])),
2094            &check_in("prod", "cron1"),
2095        ));
2096        limits.add(limit_for(&monitor_quota(None), &check_in("prod", "cron1")));
2097
2098        let propagatable = limits.propagatable();
2099        assert_eq!(propagatable.iter().count(), 1);
2100        assert!(propagatable.iter().all(|limit| limit.dimensions.is_empty()));
2101    }
2102
2103    #[test]
2104    fn test_clean_expired_drops_dimension_buckets() {
2105        let quota = monitor_quota(group_by([Dimension::CheckInSlug]));
2106        let scoping = check_in("prod", "cron1");
2107
2108        let mut limits = RateLimits::new();
2109        limits.add(RateLimit::from_quota(
2110            &quota,
2111            &scoping,
2112            RetryAfter::from_secs(0),
2113        ));
2114        limits.add(limit_for(&quota, &check_in("prod", "cron2")));
2115
2116        limits.clean_expired(Instant::now());
2117
2118        assert_eq!(limits.iter().count(), 1);
2119        assert!(!limits.check(&scoping).is_limited());
2120        assert!(limits.check(&check_in("prod", "cron2")).is_limited());
2121    }
2122
2123    #[test]
2124    fn test_has_expired_tracks_earliest_limit() {
2125        let quota = monitor_quota(group_by([Dimension::CheckInSlug]));
2126
2127        let mut limits = RateLimits::new();
2128        assert!(!limits.has_expired(Instant::now()));
2129
2130        limits.add(limit_for(&quota, &check_in("prod", "cron1")));
2131        assert!(!limits.has_expired(Instant::now()));
2132
2133        limits.add(RateLimit::from_quota(
2134            &quota,
2135            &check_in("prod", "cron2"),
2136            RetryAfter::from_secs(0),
2137        ));
2138        assert!(limits.has_expired(Instant::now()));
2139
2140        limits.clean_expired(Instant::now());
2141        assert!(!limits.has_expired(Instant::now()));
2142        assert_eq!(limits.iter().count(), 1);
2143    }
2144
2145    #[test]
2146    fn test_cached_rate_limits_round_trip_dimensions() {
2147        let quota = monitor_quota(group_by([Dimension::CheckInSlug]));
2148
2149        let cached = CachedRateLimits::new();
2150        cached.add(limit_for(&quota, &check_in("prod", "cron1")));
2151
2152        let current = cached.current_limits();
2153        assert!(current.check(&check_in("dev", "cron1")).is_limited());
2154        assert!(!current.check(&check_in("prod", "cron2")).is_limited());
2155        assert!(current.propagatable().is_empty());
2156    }
2157}