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#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)]
23pub struct RetryAfter {
24 when: Instant,
25}
26
27impl RetryAfter {
28 #[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 #[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 #[inline]
54 pub fn remaining(self) -> Option<Duration> {
55 self.remaining_at(Instant::now())
56 }
57
58 #[inline]
64 pub fn remaining_seconds_at(self, at: Instant) -> u64 {
65 match self.remaining_at(at) {
66 Some(duration) if duration.subsec_nanos() == 0 => duration.as_secs(),
68 Some(duration) => duration.as_secs() + 1,
69 None => 0,
70 }
71 }
72
73 #[inline]
78 pub fn remaining_seconds(self) -> u64 {
79 self.remaining_seconds_at(Instant::now())
80 }
81
82 #[inline]
84 pub fn expired_at(self, at: Instant) -> bool {
85 self.remaining_at(at).is_none()
86 }
87
88 #[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#[derive(Debug)]
119pub enum InvalidRetryAfter {
120 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#[derive(Clone, Copy, Debug, Eq, PartialEq, PartialOrd, Ord)]
142#[cfg_attr(test, derive(serde::Serialize))]
143pub enum RateLimitScope {
144 Organization(OrganizationId),
146 Project(ProjectId),
148 Key(ProjectKey),
150}
151
152impl RateLimitScope {
153 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 QuotaScope::Unknown => Self::Key(scoping.project_key),
164 }
165 }
166
167 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#[derive(Clone, Debug, PartialEq)]
186#[cfg_attr(test, derive(serde::Serialize))]
187pub struct RateLimit {
188 pub categories: DataCategories,
191
192 pub scope: RateLimitScope,
194
195 pub reason_code: Option<ReasonCode>,
197
198 pub retry_after: RetryAfter,
200
201 pub namespaces: SmallVec<[MetricNamespace; 1]>,
206
207 pub dimensions: DimensionMap,
210}
211
212impl RateLimit {
213 pub fn from_quota(quota: &Quota, scoping: &ItemScoping, retry_after: RetryAfter) -> Self {
218 let dimensions = if let Some(group_by) = "a.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 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 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#[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#[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#[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 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 pub fn new() -> Self {
321 Self::default()
322 }
323
324 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 self.expiry_heap.push(HeapItem {
367 retry_after: limit.retry_after,
368 rl_key: rate_limit_key,
369 });
370 *existing = limit;
371 }
372 Some(_) => (), }
374 }
375
376 pub fn merge(&mut self, limits: Self) {
382 for limit in limits {
383 self.add(limit);
384 }
385 }
386
387 pub fn merge_with(mut self, other: Self) -> Self {
391 self.merge(other);
392 self
393 }
394
395 pub fn is_ok(&self) -> bool {
399 !self.is_limited()
400 }
401
402 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 pub fn is_any_limited(&self, scopings: &[ItemScoping]) -> bool {
415 self.is_any_limited_with_quotas(&[], scopings)
416 }
417
418 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 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 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 fn matching<'a>(&'a self, scoping: &'a ItemScoping) -> impl Iterator<Item = &'a RateLimit> {
472 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 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 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 if limit.dimensions.is_empty() {
519 propagatable.add(limit.clone());
520 }
521 }
522 }
523 }
524
525 PropagatableRateLimits(propagatable)
526 }
527
528 pub fn check(&self, scoping: &ItemScoping) -> Self {
534 self.check_with_quotas(&[], scoping)
535 }
536
537 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 pub fn iter(&self) -> RateLimitsIter<'_> {
567 RateLimitsIter {
568 iter: std::slice::Iter::default(),
569 dimensioned_iter: self.limits.values(),
570 }
571 }
572
573 pub fn longest(&self) -> Option<&RateLimit> {
578 self.iter().max_by_key(|limit| limit.retry_after)
579 }
580
581 pub fn is_empty(&self) -> bool {
586 self.limits.is_empty()
587 }
588}
589
590pub 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
627pub 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#[derive(Debug, Default)]
669pub struct CachedRateLimits(Mutex<Arc<RateLimits>>);
670
671impl CachedRateLimits {
672 pub fn new() -> Self {
674 Self::default()
675 }
676
677 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 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 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 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 if inner.has_expired(now) {
730 Arc::make_mut(&mut inner).clean_expired(now);
731 }
732 Arc::clone(&inner)
733 }
734}
735
736fn 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 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 let retry_after = "17".parse::<RetryAfter>().expect("parse RetryAfter");
774 assert_eq!(retry_after.remaining_seconds(), 17);
775 assert!(!retry_after.expired());
776
777 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 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 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 "".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![], 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 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 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 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 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 #[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 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 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 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 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 assert_eq!(rate_limits.iter().count(), 2);
1409
1410 rate_limits.clean_expired(Instant::now());
1411
1412 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 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 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 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 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 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 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 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 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 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 #[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 #[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 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 #[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 assert_eq!(limits.limits.len(), 2);
1934
1935 assert!(limits.check(&monitor_scoping(Some("cron2"))).is_limited());
1936 }
1937
1938 #[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 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("a, &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("a, &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("a, &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("a, &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("a, &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("a, &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 "a,
2111 &scoping,
2112 RetryAfter::from_secs(0),
2113 ));
2114 limits.add(limit_for("a, &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("a, &check_in("prod", "cron1")));
2131 assert!(!limits.has_expired(Instant::now()));
2132
2133 limits.add(RateLimit::from_quota(
2134 "a,
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("a, &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}