1use std::fmt::{self, Debug, Write};
2use std::sync::Arc;
3
4use itertools::Itertools;
5use relay_base_schema::metrics::MetricNamespace;
6use relay_base_schema::organization::OrganizationId;
7use relay_common::time::UnixTimestamp;
8use relay_log::protocol::value;
9use relay_redis::redis::{self, FromRedisValue, ParsingError, Script};
10use relay_redis::{AsyncRedisClient, RedisError, RedisScripts};
11use thiserror::Error;
12
13use crate::cache::OpportunisticQuotaCache;
14use crate::quota::{ItemScoping, Quota, QuotaScope};
15use crate::rate_limit::{RateLimit, RateLimits, RetryAfter};
16use crate::statsd::{QuotaCounters, QuotaTimers};
17use crate::{REJECT_ALL_SECS, cache};
18
19const GRACE: u64 = 60;
23
24#[derive(Debug, Error)]
26#[error("failed to communicate with redis")]
27pub struct RateLimitingError(
28 #[from]
29 #[source]
30 pub RedisError,
31);
32
33fn get_refunded_quota_key(counter_key: &str) -> String {
38 format!("r:{counter_key}")
39}
40
41struct OptionalDisplay<T>(Option<T>);
43
44impl<T> fmt::Display for OptionalDisplay<T>
45where
46 T: fmt::Display,
47{
48 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49 match self.0 {
50 Some(ref value) => write!(f, "{value}"),
51 None => Ok(()),
52 }
53 }
54}
55
56#[derive(Debug, Clone)]
58pub struct OwnedRedisQuota {
59 quota: Quota,
61 scoping: ItemScoping,
63 prefix: Arc<str>,
65 window: u64,
67 quantity: u64,
69 timestamp: UnixTimestamp,
71}
72
73impl OwnedRedisQuota {
74 pub fn build_ref(&self) -> RedisQuota<'_> {
76 RedisQuota {
77 quota: &self.quota,
78 scoping: &self.scoping,
79 prefix: Arc::clone(&self.prefix),
80 window: self.window,
81 quantity: self.quantity,
82 timestamp: self.timestamp,
83 }
84 }
85}
86
87#[derive(Debug, Clone, Eq, PartialEq)]
89pub struct RedisQuota<'a> {
90 quota: &'a Quota,
92 scoping: &'a ItemScoping,
94 prefix: Arc<str>,
96 window: u64,
98 quantity: u64,
100 timestamp: UnixTimestamp,
102}
103
104impl<'a> RedisQuota<'a> {
105 pub fn new(
111 quota: &'a Quota,
112 quantity: u64,
113 scoping: &'a ItemScoping,
114 timestamp: UnixTimestamp,
115 ) -> Option<Self> {
116 let prefix = quota.id.clone()?;
118 let window = quota.window?;
119
120 Some(Self {
121 quota,
122 scoping,
123 prefix,
124 quantity,
125 window,
126 timestamp,
127 })
128 }
129
130 pub fn build_owned(&self) -> OwnedRedisQuota {
133 OwnedRedisQuota {
134 quota: self.quota.clone(),
135 scoping: self.scoping.clone(),
136 prefix: Arc::clone(&self.prefix),
137 window: self.window,
138 quantity: self.quantity,
139 timestamp: self.timestamp,
140 }
141 }
142
143 pub fn window(&self) -> u64 {
145 self.window
146 }
147
148 pub fn prefix(&self) -> &str {
150 &self.prefix
151 }
152
153 pub fn quantity(&self) -> u64 {
155 self.quantity
156 }
157
158 pub fn limit(&self) -> i64 {
163 self.limit
164 .and_then(|limit| limit.try_into().ok())
166 .unwrap_or(-1)
167 }
168
169 fn shift(&self) -> u64 {
170 self.scoping.organization_id.value() % self.window
171 }
172
173 pub fn slot(&self) -> u64 {
177 (self.timestamp.as_secs() - self.shift()) / self.window
178 }
179
180 pub fn expiry(&self) -> UnixTimestamp {
182 let next_slot = self.slot() + 1;
183 let next_start = next_slot * self.window + self.shift();
184 UnixTimestamp::from_secs(next_start)
185 }
186
187 pub fn key_expiry(&self) -> u64 {
191 self.expiry().as_secs() + GRACE
192 }
193
194 pub fn key(&self) -> QuotaCacheKey {
200 let subscope = match self.quota.scope {
203 QuotaScope::Organization => None,
204 scope => self.scoping.scope_id(scope),
205 };
206
207 QuotaCacheKey {
208 id: Arc::clone(&self.prefix),
209 org: self.scoping.organization_id,
210 subscope,
211 namespace: self.namespace,
212 slot: self.slot(),
213 dimensions: self.scoping.dimensions.to_string(self.quota),
214 }
215 }
216
217 pub fn max_dimensions_cardinality(&self) -> u32 {
222 if let Some(group_by) = &self.group_by {
223 group_by.max_cardinality
224 } else {
225 1
226 }
227 }
228
229 fn for_cache(&self) -> cache::Quota<QuotaCacheKey> {
231 cache::Quota {
232 limit: self.limit(),
233 window: self.window,
234 key: self.key(),
235 expiry: UnixTimestamp::from_secs(self.key_expiry()),
236 }
237 }
238}
239
240impl std::ops::Deref for RedisQuota<'_> {
241 type Target = Quota;
242
243 fn deref(&self) -> &Self::Target {
244 self.quota
245 }
246}
247
248#[derive(Debug, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
254pub struct QuotaCacheKey {
255 id: Arc<str>,
256 org: OrganizationId,
257 subscope: Option<u64>,
258 namespace: Option<MetricNamespace>,
259 slot: u64,
260 dimensions: Option<String>,
261}
262
263struct RedisQuotaKey {
264 redis_key: String,
265 hash_key: Option<String>,
266}
267
268impl QuotaCacheKey {
269 fn to_redis_quota_key(&self) -> RedisQuotaKey {
270 let mut redis_key = String::new();
271
272 write!(
273 &mut redis_key,
274 "quota:{id}{{{org}}}{subscope}{namespace}:{slot}",
275 id = self.id,
276 org = self.org,
277 subscope = OptionalDisplay(self.subscope),
278 namespace = OptionalDisplay(self.namespace),
279 slot = self.slot,
280 )
281 .expect("should be infallible");
282
283 RedisQuotaKey {
284 redis_key,
285 hash_key: self.dimensions.clone(),
286 }
287 }
288}
289
290#[derive(Clone)]
301pub struct RedisRateLimiter {
302 client: AsyncRedisClient,
303 cache: Option<Arc<OpportunisticQuotaCache<QuotaCacheKey>>>,
304 script: &'static Script,
305 max_limit: Option<u64>,
306}
307
308impl RedisRateLimiter {
309 pub fn new(client: AsyncRedisClient) -> Self {
311 RedisRateLimiter {
312 client,
313 cache: None,
314 script: RedisScripts::load_is_rate_limited(),
315 max_limit: None,
316 }
317 }
318
319 pub fn max_limit(mut self, max_limit: Option<u64>) -> Self {
324 self.max_limit = max_limit;
325 self
326 }
327
328 pub fn cache(mut self, cache_ratio: Option<f32>, max: Option<f32>) -> Self {
336 self.cache = cache_ratio
337 .map(OpportunisticQuotaCache::new)
338 .map(|c| c.with_max(max))
339 .map(Arc::new);
340
341 self
342 }
343
344 pub async fn is_rate_limited<'a>(
361 &self,
362 quotas: impl IntoIterator<Item = &'a Quota>,
363 item_scoping: &ItemScoping,
364 quantity: usize,
365 over_accept_once: bool,
366 ) -> Result<RateLimits, RateLimitingError> {
367 let timestamp = UnixTimestamp::now();
368 let mut invocation = self.script.prepare_invoke();
369 let mut tracked_quotas = Vec::new();
370 let mut rate_limits = RateLimits::new();
371
372 let quantity = u64::try_from(quantity).unwrap_or(u64::MAX);
373
374 for quota in quotas {
375 if !quota.matches(item_scoping) {
376 } else if quota.limit == Some(0) {
378 let retry_after = self.retry_after(REJECT_ALL_SECS);
382 rate_limits.add(RateLimit::from_quota(quota, item_scoping, retry_after));
383 } else if let Some(mut quota) =
384 RedisQuota::new(quota, quantity, item_scoping, timestamp)
385 {
386 if let Some(cache) = &self.cache {
387 quota.quantity = match cache.check_quota(quota.for_cache(), quantity) {
388 cache::Action::Accept => continue,
389 cache::Action::Check(quantity) => quantity,
390 };
391 }
392
393 let redis_quota_key = quota.key().to_redis_quota_key();
394 let mut redis_key = redis_quota_key.redis_key;
395 let refund_key = get_refunded_quota_key(&redis_key);
397
398 let redis_dims_key = redis_quota_key.hash_key;
399
400 if redis_dims_key.is_some() {
401 redis_key += ":hash";
402 }
403
404 let redis_dims_key = redis_dims_key.as_deref().unwrap_or(crate::EMPTY_DIMENSIONS);
405
406 let max_cardinality = quota.max_dimensions_cardinality();
407
408 invocation.key(redis_key);
409 invocation.key(refund_key);
410
411 invocation.arg(quota.limit());
412 invocation.arg(quota.key_expiry());
413 invocation.arg(quota.quantity);
414 invocation.arg(over_accept_once);
415 invocation.arg(redis_dims_key);
416 invocation.arg(max_cardinality);
417
418 tracked_quotas.push(quota);
419 } else {
420 relay_log::with_scope(
423 |scope| scope.set_extra("quota", value::to_value(quota).unwrap()),
424 || relay_log::warn!("skipping unsupported quota"),
425 )
426 }
427 }
428
429 if tracked_quotas.is_empty() || rate_limits.is_limited() {
432 return Ok(rate_limits);
433 }
434
435 let mut connection = self.client.get_connection().await?;
436 let result: ScriptResult = invocation
437 .invoke_async(&mut connection)
438 .await
439 .map_err(RedisError::Redis)?;
440
441 for (quota, state) in tracked_quotas.iter().zip(result.0) {
442 if state.is_rejected {
443 let cache_error = {
446 let remaining = quota.limit().saturating_sub(state.consumed).max(0) as u64;
447 let cache_quantity = quota.quantity.saturating_sub(quantity);
448
449 cache_quantity.saturating_sub(remaining)
450 };
451 relay_statsd::metric!(
452 counter(QuotaCounters::CacheError) += cache_error,
453 category = item_scoping.category.name(),
454 );
455
456 let retry_after = self.retry_after((quota.expiry() - timestamp).as_secs());
457 rate_limits.add(RateLimit::from_quota(quota, item_scoping, retry_after));
458 } else if let Some(cache) = &self.cache {
459 cache.set_quota(quota.for_cache(), state.consumed);
462 }
463 }
464 drop(connection);
465
466 if let Some(cache) = &self.cache {
467 let vacuum_start = std::time::Instant::now();
468 if cache.try_vacuum(timestamp) {
469 relay_statsd::metric!(
470 timer(QuotaTimers::CacheVacuumDuration) = vacuum_start.elapsed()
471 );
472 }
473 }
474
475 Ok(rate_limits)
476 }
477
478 fn retry_after(&self, mut seconds: u64) -> RetryAfter {
482 if let Some(max_limit) = self.max_limit {
483 seconds = std::cmp::min(seconds, max_limit);
484 }
485
486 RetryAfter::from_secs(seconds)
487 }
488}
489
490#[derive(Debug)]
492struct ScriptResult(Vec<QuotaState>);
493
494impl FromRedisValue for ScriptResult {
495 fn from_redis_value(v: redis::Value) -> Result<Self, ParsingError> {
496 let seq = v.into_sequence().map_err(|v| {
497 format!("Expected a sequence from the rate limiting script (value was: {v:?})")
498 })?;
499
500 if !seq.len().is_multiple_of(2) {
501 return Err(format!(
502 "Expected an even number of values from the rate limiting script (value was: {seq:?})"
503 ).into());
504 }
505
506 let mut quotas = Vec::with_capacity(seq.len() / 2);
507 for (is_rejected, consumed) in seq.into_iter().tuples() {
508 quotas.push(QuotaState {
509 is_rejected: bool::from_redis_value(is_rejected)?,
510 consumed: i64::from_redis_value(consumed)?,
511 });
512 }
513
514 Ok(Self(quotas))
515 }
516}
517
518#[derive(Debug)]
520struct QuotaState {
521 is_rejected: bool,
523 consumed: i64,
525}
526
527#[cfg(test)]
528mod tests {
529 use std::collections::BTreeSet;
530 use std::time::{SystemTime, UNIX_EPOCH};
531
532 use super::*;
533 use crate::quota::{DataCategories, DataCategory, ReasonCode, Scoping};
534 use crate::rate_limit::RateLimitScope;
535 use crate::{Dimension, DimensionMap, EMPTY_DIMENSIONS, GroupBy, MetricNamespaceScoping};
536 use relay_base_schema::metrics::MetricNamespace;
537 use relay_base_schema::organization::OrganizationId;
538 use relay_base_schema::project::{ProjectId, ProjectKey};
539 use relay_redis::RedisConfigOptions;
540 use relay_redis::redis::AsyncCommands;
541 use smallvec::smallvec;
542
543 fn build_rate_limiter() -> RedisRateLimiter {
544 let url = std::env::var("RELAY_REDIS_URL")
545 .unwrap_or_else(|_| "redis://127.0.0.1:6379".to_owned());
546 let client =
547 AsyncRedisClient::single("test", &url, &RedisConfigOptions::default()).unwrap();
548
549 RedisRateLimiter {
550 client,
551 cache: None,
552 script: RedisScripts::load_is_rate_limited(),
553 max_limit: None,
554 }
555 }
556
557 #[tokio::test]
558 async fn test_zero_size_quotas() {
559 let quotas = &[
560 Quota {
561 id: None,
562 categories: DataCategories::new(),
563 scope: QuotaScope::Organization,
564 scope_id: None,
565 limit: Some(0),
566 window: None,
567 reason_code: Some(ReasonCode::new("get_lost")),
568 namespace: None,
569 group_by: None,
570 },
571 Quota {
572 id: Some("42".into()),
573 categories: DataCategories::new(),
574 scope: QuotaScope::Organization,
575 scope_id: None,
576 limit: None,
577 window: Some(42),
578 reason_code: Some(ReasonCode::new("unlimited")),
579 namespace: None,
580 group_by: None,
581 },
582 ];
583
584 let scoping = ItemScoping {
585 category: DataCategory::Error,
586 scoping: Scoping {
587 organization_id: OrganizationId::new(42),
588 project_id: ProjectId::new(43),
589 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
590 key_id: Some(44),
591 },
592 namespace: MetricNamespaceScoping::None,
593 dimensions: DimensionMap::default(),
594 };
595
596 let rate_limits: Vec<RateLimit> = build_rate_limiter()
597 .is_rate_limited(quotas, &scoping, 1, false)
598 .await
599 .expect("rate limiting failed")
600 .into_iter()
601 .collect();
602
603 assert_eq!(
604 rate_limits,
605 vec![RateLimit {
606 categories: DataCategories::new(),
607 scope: RateLimitScope::Organization(OrganizationId::new(42)),
608 reason_code: Some(ReasonCode::new("get_lost")),
609 retry_after: rate_limits[0].retry_after,
610 namespaces: smallvec![],
611 dimensional: false,
612 }]
613 );
614 }
615
616 #[tokio::test]
618 async fn test_namespace_quota() {
619 let quota_limit = 5;
620 let get_quota = |namespace: Option<MetricNamespace>| -> Quota {
621 Quota {
622 id: Some(format!("test_simple_quota_{}", uuid::Uuid::new_v4()).into()),
623 categories: DataCategories::new(),
624 scope: QuotaScope::Organization,
625 scope_id: None,
626 limit: Some(quota_limit),
627 window: Some(600),
628 reason_code: Some(ReasonCode::new(format!("ns: {namespace:?}"))),
629 namespace,
630 group_by: None,
631 }
632 };
633
634 let quotas = &[get_quota(None)];
635 let quota_with_namespace = &[get_quota(Some(MetricNamespace::Sessions))];
636
637 let scoping = ItemScoping {
638 category: DataCategory::Error,
639 scoping: Scoping {
640 organization_id: OrganizationId::new(42),
641 project_id: ProjectId::new(43),
642 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
643 key_id: Some(44),
644 },
645 namespace: MetricNamespaceScoping::Some(MetricNamespace::Sessions),
646 dimensions: DimensionMap::default(),
647 };
648
649 let rate_limiter = build_rate_limiter();
650
651 for i in 0..10 {
653 let rate_limits: Vec<RateLimit> = rate_limiter
654 .is_rate_limited(quotas, &scoping, 1, false)
655 .await
656 .expect("rate limiting failed")
657 .into_iter()
658 .collect();
659
660 if i < quota_limit {
661 assert_eq!(rate_limits, vec![]);
662 } else {
663 assert_eq!(
664 rate_limits[0].reason_code,
665 Some(ReasonCode::new("ns: None"))
666 );
667 }
668 }
669
670 for i in 0..10 {
672 let rate_limits: Vec<RateLimit> = rate_limiter
673 .is_rate_limited(quota_with_namespace, &scoping, 1, false)
674 .await
675 .expect("rate limiting failed")
676 .into_iter()
677 .collect();
678
679 if i < quota_limit {
680 assert_eq!(rate_limits, vec![]);
681 } else {
682 assert_eq!(
683 rate_limits[0].reason_code,
684 Some(ReasonCode::new("ns: Some(Sessions)"))
685 );
686 }
687 }
688 }
689
690 #[tokio::test]
691 async fn test_simple_quota() {
692 let quotas = &[Quota {
693 id: Some(format!("test_simple_quota_{}", uuid::Uuid::new_v4()).into()),
694 categories: DataCategories::new(),
695 scope: QuotaScope::Organization,
696 scope_id: None,
697 limit: Some(5),
698 window: Some(60),
699 reason_code: Some(ReasonCode::new("get_lost")),
700 namespace: None,
701 group_by: None,
702 }];
703
704 let scoping = ItemScoping {
705 category: DataCategory::Error,
706 scoping: Scoping {
707 organization_id: OrganizationId::new(42),
708 project_id: ProjectId::new(43),
709 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
710 key_id: Some(44),
711 },
712 namespace: MetricNamespaceScoping::None,
713 dimensions: DimensionMap::default(),
714 };
715
716 let rate_limiter = build_rate_limiter();
717
718 for i in 0..10 {
719 let rate_limits: Vec<RateLimit> = rate_limiter
720 .is_rate_limited(quotas, &scoping, 1, false)
721 .await
722 .expect("rate limiting failed")
723 .into_iter()
724 .collect();
725
726 if i >= 5 {
727 assert_eq!(
728 rate_limits,
729 vec![RateLimit {
730 categories: DataCategories::new(),
731 scope: RateLimitScope::Organization(OrganizationId::new(42)),
732 reason_code: Some(ReasonCode::new("get_lost")),
733 retry_after: rate_limits[0].retry_after,
734 namespaces: smallvec![],
735 dimensional: false,
736 }]
737 );
738 } else {
739 assert_eq!(rate_limits, vec![]);
740 }
741 }
742 }
743
744 #[tokio::test]
745 async fn test_quantity_0() {
746 let quotas = &[Quota {
747 id: Some(format!("test_quantity_0_{}", uuid::Uuid::new_v4()).into()),
748 categories: DataCategories::new(),
749 scope: QuotaScope::Organization,
750 scope_id: None,
751 limit: Some(1),
752 window: Some(60),
753 reason_code: Some(ReasonCode::new("get_lost")),
754 namespace: None,
755 group_by: None,
756 }];
757
758 let scoping = ItemScoping {
759 category: DataCategory::Error,
760 scoping: Scoping {
761 organization_id: OrganizationId::new(42),
762 project_id: ProjectId::new(43),
763 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
764 key_id: Some(44),
765 },
766 namespace: MetricNamespaceScoping::None,
767 dimensions: DimensionMap::default(),
768 };
769
770 let rate_limiter = build_rate_limiter();
771
772 assert!(
774 !rate_limiter
775 .is_rate_limited(quotas, &scoping, 1, false)
776 .await
777 .unwrap()
778 .is_limited()
779 );
780
781 assert!(
783 rate_limiter
784 .is_rate_limited(quotas, &scoping, 1, false)
785 .await
786 .unwrap()
787 .is_limited()
788 );
789
790 assert!(
792 rate_limiter
793 .is_rate_limited(quotas, &scoping, 0, false)
794 .await
795 .unwrap()
796 .is_limited()
797 );
798
799 assert!(
801 rate_limiter
802 .is_rate_limited(quotas, &scoping, 1, false)
803 .await
804 .unwrap()
805 .is_limited()
806 );
807 }
808
809 #[tokio::test]
810 async fn test_quota_go_over() {
811 let quotas = &[Quota {
812 id: Some(format!("test_quota_go_over{}", uuid::Uuid::new_v4()).into()),
813 categories: DataCategories::new(),
814 scope: QuotaScope::Organization,
815 scope_id: None,
816 limit: Some(2),
817 window: Some(60),
818 reason_code: Some(ReasonCode::new("get_lost")),
819 namespace: None,
820 group_by: None,
821 }];
822
823 let scoping = ItemScoping {
824 category: DataCategory::Error,
825 scoping: Scoping {
826 organization_id: OrganizationId::new(42),
827 project_id: ProjectId::new(43),
828 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
829 key_id: Some(44),
830 },
831 namespace: MetricNamespaceScoping::None,
832 dimensions: DimensionMap::default(),
833 };
834
835 let rate_limiter = build_rate_limiter();
836
837 let is_limited = rate_limiter
839 .is_rate_limited(quotas, &scoping, 1, true)
840 .await
841 .unwrap()
842 .is_limited();
843 assert!(!is_limited);
844
845 let is_limited = rate_limiter
847 .is_rate_limited(quotas, &scoping, 2, true)
848 .await
849 .unwrap()
850 .is_limited();
851 assert!(!is_limited);
852
853 let is_limited = rate_limiter
855 .is_rate_limited(quotas, &scoping, 0, true)
856 .await
857 .unwrap()
858 .is_limited();
859 assert!(is_limited);
860
861 let is_limited = rate_limiter
863 .is_rate_limited(quotas, &scoping, 1, true)
864 .await
865 .unwrap()
866 .is_limited();
867 assert!(is_limited);
868 }
869
870 #[tokio::test]
871 async fn test_bails_immediately_without_any_quota() {
872 let scoping = ItemScoping {
873 category: DataCategory::Error,
874 scoping: Scoping {
875 organization_id: OrganizationId::new(42),
876 project_id: ProjectId::new(43),
877 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
878 key_id: Some(44),
879 },
880 namespace: MetricNamespaceScoping::None,
881 dimensions: DimensionMap::default(),
882 };
883
884 let rate_limits: Vec<RateLimit> = build_rate_limiter()
885 .is_rate_limited(&[], &scoping, 1, false)
886 .await
887 .expect("rate limiting failed")
888 .into_iter()
889 .collect();
890
891 assert_eq!(rate_limits, vec![]);
892 }
893
894 #[tokio::test]
895 async fn test_limited_with_unlimited_quota() {
896 let quotas = &[
897 Quota {
898 id: Some("q0".into()),
899 categories: DataCategories::new(),
900 scope: QuotaScope::Organization,
901 scope_id: None,
902 limit: None,
903 window: Some(1),
904 reason_code: Some(ReasonCode::new("project_quota0")),
905 namespace: None,
906 group_by: None,
907 },
908 Quota {
909 id: Some("q1".into()),
910 categories: DataCategories::new(),
911 scope: QuotaScope::Organization,
912 scope_id: None,
913 limit: Some(1),
914 window: Some(1),
915 reason_code: Some(ReasonCode::new("project_quota1")),
916 namespace: None,
917 group_by: None,
918 },
919 ];
920
921 let scoping = ItemScoping {
922 category: DataCategory::Error,
923 scoping: Scoping {
924 organization_id: OrganizationId::new(42),
925 project_id: ProjectId::new(43),
926 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
927 key_id: Some(44),
928 },
929 namespace: MetricNamespaceScoping::None,
930 dimensions: DimensionMap::default(),
931 };
932
933 let rate_limiter = build_rate_limiter();
934
935 for i in 0..1 {
936 let rate_limits: Vec<RateLimit> = rate_limiter
937 .is_rate_limited(quotas, &scoping, 1, false)
938 .await
939 .expect("rate limiting failed")
940 .into_iter()
941 .collect();
942
943 if i == 0 {
944 assert_eq!(rate_limits, &[]);
945 } else {
946 assert_eq!(
947 rate_limits,
948 vec![RateLimit {
949 categories: DataCategories::new(),
950 scope: RateLimitScope::Organization(OrganizationId::new(42)),
951 reason_code: Some(ReasonCode::new("project_quota1")),
952 retry_after: rate_limits[0].retry_after,
953 namespaces: smallvec![],
954 dimensional: false,
955 }]
956 );
957 }
958 }
959 }
960
961 #[tokio::test]
962 async fn test_quota_with_quantity() {
963 let quotas = &[Quota {
964 id: Some(format!("test_quantity_quota_{}", uuid::Uuid::new_v4()).into()),
965 categories: DataCategories::new(),
966 scope: QuotaScope::Organization,
967 scope_id: None,
968 limit: Some(500),
969 window: Some(60),
970 reason_code: Some(ReasonCode::new("get_lost")),
971 namespace: None,
972 group_by: None,
973 }];
974
975 let scoping = ItemScoping {
976 category: DataCategory::Error,
977 scoping: Scoping {
978 organization_id: OrganizationId::new(42),
979 project_id: ProjectId::new(43),
980 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
981 key_id: Some(44),
982 },
983 namespace: MetricNamespaceScoping::None,
984 dimensions: DimensionMap::default(),
985 };
986
987 let rate_limiter = build_rate_limiter();
988
989 for i in 0..10 {
990 let rate_limits: Vec<RateLimit> = rate_limiter
991 .is_rate_limited(quotas, &scoping, 100, false)
992 .await
993 .expect("rate limiting failed")
994 .into_iter()
995 .collect();
996
997 if i >= 5 {
998 assert_eq!(
999 rate_limits,
1000 vec![RateLimit {
1001 categories: DataCategories::new(),
1002 scope: RateLimitScope::Organization(OrganizationId::new(42)),
1003 reason_code: Some(ReasonCode::new("get_lost")),
1004 retry_after: rate_limits[0].retry_after,
1005 namespaces: smallvec![],
1006 dimensional: false,
1007 }]
1008 );
1009 } else {
1010 assert_eq!(rate_limits, vec![]);
1011 }
1012 }
1013 }
1014
1015 #[tokio::test]
1016 async fn test_get_redis_key_scoped() {
1017 let quota = Quota {
1018 id: Some("foo".into()),
1019 categories: DataCategories::new(),
1020 scope: QuotaScope::Project,
1021 scope_id: Some("42".into()),
1022 window: Some(2),
1023 limit: Some(0),
1024 reason_code: None,
1025 namespace: None,
1026 group_by: None,
1027 };
1028
1029 let scoping = ItemScoping {
1030 category: DataCategory::Error,
1031 scoping: Scoping {
1032 organization_id: OrganizationId::new(69420),
1033 project_id: ProjectId::new(42),
1034 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1035 key_id: Some(4711),
1036 },
1037 namespace: MetricNamespaceScoping::None,
1038 dimensions: DimensionMap::default(),
1039 };
1040
1041 let timestamp = UnixTimestamp::from_secs(123_123_123);
1042 let redis_quota = RedisQuota::new("a, 0, &scoping, timestamp).unwrap();
1043 assert_eq!(
1044 redis_quota.key().to_redis_quota_key().redis_key,
1045 "quota:foo{69420}42:61561561"
1046 );
1047 }
1048
1049 #[tokio::test]
1050 async fn test_get_redis_key_unscoped() {
1051 let quota = Quota {
1052 id: Some("foo".into()),
1053 categories: DataCategories::new(),
1054 scope: QuotaScope::Organization,
1055 scope_id: None,
1056 window: Some(10),
1057 limit: Some(0),
1058 reason_code: None,
1059 namespace: None,
1060 group_by: None,
1061 };
1062
1063 let scoping = ItemScoping {
1064 category: DataCategory::Error,
1065 scoping: Scoping {
1066 organization_id: OrganizationId::new(69420),
1067 project_id: ProjectId::new(42),
1068 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1069 key_id: Some(4711),
1070 },
1071 namespace: MetricNamespaceScoping::None,
1072 dimensions: DimensionMap::default(),
1073 };
1074
1075 let timestamp = UnixTimestamp::from_secs(234_531);
1076 let redis_quota = RedisQuota::new("a, 0, &scoping, timestamp).unwrap();
1077 assert_eq!(
1078 redis_quota.key().to_redis_quota_key().redis_key,
1079 "quota:foo{69420}:23453"
1080 );
1081 }
1082
1083 #[tokio::test]
1084 async fn test_large_redis_limit_large() {
1085 let quota = Quota {
1086 id: Some("foo".into()),
1087 categories: DataCategories::new(),
1088 scope: QuotaScope::Organization,
1089 scope_id: None,
1090 window: Some(10),
1091 limit: Some(9223372036854775808), reason_code: None,
1093 namespace: None,
1094 group_by: None,
1095 };
1096
1097 let scoping = ItemScoping {
1098 category: DataCategory::Error,
1099 scoping: Scoping {
1100 organization_id: OrganizationId::new(69420),
1101 project_id: ProjectId::new(42),
1102 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1103 key_id: Some(4711),
1104 },
1105 namespace: MetricNamespaceScoping::None,
1106 dimensions: DimensionMap::default(),
1107 };
1108
1109 let timestamp = UnixTimestamp::from_secs(234_531);
1110 let redis_quota = RedisQuota::new("a, 0, &scoping, timestamp).unwrap();
1111 assert_eq!(redis_quota.limit(), -1);
1112 }
1113
1114 #[tokio::test]
1115 async fn test_is_rate_limited_script() {
1116 let now = SystemTime::now()
1117 .duration_since(UNIX_EPOCH)
1118 .map(|duration| duration.as_secs())
1119 .unwrap();
1120
1121 let rate_limiter = build_rate_limiter();
1122 let mut conn = rate_limiter.client.get_connection().await.unwrap();
1123
1124 let foo = format!("foo___{now}");
1126 let r_foo = format!("r:foo___{now}");
1127 let bar = format!("bar___{now}");
1128 let r_bar = format!("r:bar___{now}");
1129 let apple = format!("apple___{now}");
1130 let orange = format!("orange___{now}");
1131 let baz = format!("baz___{now}");
1132
1133 let script = RedisScripts::load_is_rate_limited();
1134
1135 macro_rules! assert_invocation {
1136 ($invocation:expr, $($tt:tt)*) => {{
1137 let result = $invocation
1138 .invoke_async::<ScriptResult>(&mut conn)
1139 .await
1140 .unwrap();
1141
1142 insta::assert_debug_snapshot!(result, $($tt)*);
1143 }};
1144 }
1145
1146 let mut invocation = script.prepare_invoke();
1147 invocation
1148 .key(&foo) .key(&r_foo) .key(&bar) .key(&r_bar) .arg(1) .arg(now + 60) .arg(1) .arg(false) .arg(EMPTY_DIMENSIONS) .arg(1) .arg(2) .arg(now + 120) .arg(1) .arg(false) .arg(EMPTY_DIMENSIONS) .arg(1); let mut invocation2 = script.prepare_invoke();
1168 invocation2
1169 .key(&bar) .key(&r_bar) .arg(2) .arg(now + 120) .arg(1) .arg(false) .arg(EMPTY_DIMENSIONS) .arg(1); assert_invocation!(invocation, @r"
1180 ScriptResult(
1181 [
1182 QuotaState {
1183 is_rejected: false,
1184 consumed: 1,
1185 },
1186 QuotaState {
1187 is_rejected: false,
1188 consumed: 1,
1189 },
1190 ],
1191 )
1192 "
1193 );
1194
1195 assert_invocation!(invocation, @r"
1198 ScriptResult(
1199 [
1200 QuotaState {
1201 is_rejected: true,
1202 consumed: 1,
1203 },
1204 QuotaState {
1205 is_rejected: false,
1206 consumed: 1,
1207 },
1208 ],
1209 )
1210 "
1211 );
1212
1213 assert_invocation!(invocation, @r"
1215 ScriptResult(
1216 [
1217 QuotaState {
1218 is_rejected: true,
1219 consumed: 1,
1220 },
1221 QuotaState {
1222 is_rejected: false,
1223 consumed: 1,
1224 },
1225 ],
1226 )
1227 "
1228 );
1229
1230 assert_invocation!(invocation2, @r"
1233 ScriptResult(
1234 [
1235 QuotaState {
1236 is_rejected: false,
1237 consumed: 2,
1238 },
1239 ],
1240 )
1241 "
1242 );
1243
1244 assert_invocation!(invocation2, @r"
1246 ScriptResult(
1247 [
1248 QuotaState {
1249 is_rejected: true,
1250 consumed: 2,
1251 },
1252 ],
1253 )
1254 "
1255 );
1256
1257 assert_invocation!(invocation, @r"
1259 ScriptResult(
1260 [
1261 QuotaState {
1262 is_rejected: true,
1263 consumed: 1,
1264 },
1265 QuotaState {
1266 is_rejected: true,
1267 consumed: 2,
1268 },
1269 ],
1270 )
1271 "
1272 );
1273
1274 assert_eq!(conn.get::<_, String>(&foo).await.unwrap(), "1");
1275
1276 let ttl: u64 = conn.ttl(&foo).await.unwrap();
1277 assert!(ttl >= 59);
1278 assert!(ttl <= 60);
1279
1280 assert_eq!(conn.get::<_, String>(&bar).await.unwrap(), "2");
1281
1282 let ttl: u64 = conn.ttl(&bar).await.unwrap();
1283 assert!(ttl >= 119);
1284 assert!(ttl <= 120);
1285
1286 let () = conn.get(r_foo).await.unwrap();
1288 let () = conn.get(r_bar).await.unwrap();
1289
1290 let () = conn.set(&apple, 5).await.unwrap();
1292
1293 let mut invocation = script.prepare_invoke();
1294 invocation
1295 .key(&orange) .key(&baz) .arg(1) .arg(now + 60) .arg(1) .arg(false) .arg(EMPTY_DIMENSIONS) .arg(1); assert_invocation!(invocation, @r"
1306 ScriptResult(
1307 [
1308 QuotaState {
1309 is_rejected: false,
1310 consumed: 1,
1311 },
1312 ],
1313 )
1314 "
1315 );
1316
1317 assert_invocation!(invocation, @r"
1319 ScriptResult(
1320 [
1321 QuotaState {
1322 is_rejected: true,
1323 consumed: 1,
1324 },
1325 ],
1326 )
1327 "
1328 );
1329
1330 assert_invocation!(invocation, @r"
1332 ScriptResult(
1333 [
1334 QuotaState {
1335 is_rejected: true,
1336 consumed: 1,
1337 },
1338 ],
1339 )
1340 "
1341 );
1342
1343 let mut invocation = script.prepare_invoke();
1344 invocation
1345 .key(&orange) .key(&apple) .arg(1) .arg(now + 60) .arg(1) .arg(false) .arg(EMPTY_DIMENSIONS) .arg(1); assert_invocation!(invocation, @r"
1356 ScriptResult(
1357 [
1358 QuotaState {
1359 is_rejected: false,
1360 consumed: -3,
1361 },
1362 ],
1363 )
1364 "
1365 );
1366 }
1367
1368 #[tokio::test]
1370 async fn test_is_rate_limited_script_dimensions() {
1371 let now = SystemTime::now()
1372 .duration_since(UNIX_EPOCH)
1373 .map(|duration| duration.as_secs())
1374 .unwrap();
1375
1376 let rate_limiter = build_rate_limiter();
1377 let mut conn = rate_limiter.client.get_connection().await.unwrap();
1378
1379 let key = format!("dims___{now}");
1380 let refund_key = format!("r:dims___{now}");
1381
1382 let script = RedisScripts::load_is_rate_limited();
1383
1384 let invoke = |dims: &str| {
1386 let mut invocation = script.prepare_invoke();
1387
1388 let mut key = key.clone();
1389 if dims != EMPTY_DIMENSIONS {
1390 key += ":hash";
1391 }
1392
1393 invocation
1394 .key(&key) .key(&refund_key) .arg(1) .arg(now + 60) .arg(1) .arg(false) .arg(dims) .arg(999); invocation
1403 };
1404
1405 let invoke_async = async |dims: &str, conn: &mut _| {
1406 invoke(dims)
1407 .invoke_async::<ScriptResult>(conn)
1408 .await
1409 .unwrap()
1410 .0
1411 };
1412
1413 let result = invoke_async(":2:a", &mut conn).await;
1415 assert!(!result[0].is_rejected);
1416 assert_eq!(result[0].consumed, 1);
1417
1418 let result = invoke_async(":2:a", &mut conn).await;
1420 assert!(result[0].is_rejected);
1421 assert_eq!(result[0].consumed, 1);
1422
1423 let result = invoke_async(":2:b", &mut conn).await;
1425 assert!(!result[0].is_rejected);
1426 assert_eq!(result[0].consumed, 1);
1427
1428 let result = invoke_async(":2:b", &mut conn).await;
1429 assert!(result[0].is_rejected);
1430 assert_eq!(result[0].consumed, 1);
1431
1432 let result = invoke_async(EMPTY_DIMENSIONS, &mut conn).await;
1434 assert!(!result[0].is_rejected);
1435 assert_eq!(result[0].consumed, 1);
1436
1437 let mut fields: Vec<(String, i64)> = conn.hgetall(&(key.clone() + ":hash")).await.unwrap();
1439 fields.sort();
1440 assert_eq!(
1441 fields,
1442 vec![(":2:a".to_owned(), 1), (":2:b".to_owned(), 1),]
1443 );
1444
1445 let ttl: u64 = conn.ttl(&key).await.unwrap();
1446 assert!(ttl >= 59, "{ttl}");
1447 assert!(ttl <= 60, "{ttl}");
1448 }
1449
1450 #[tokio::test]
1453 async fn test_is_rate_limited_script_dimensions_cardinality() {
1454 let now = SystemTime::now()
1455 .duration_since(UNIX_EPOCH)
1456 .map(|duration| duration.as_secs())
1457 .unwrap();
1458
1459 let rate_limiter = build_rate_limiter();
1460 let mut conn = rate_limiter.client.get_connection().await.unwrap();
1461
1462 let key = format!("cardinality___{now}");
1463 let refund_key = format!("r:cardinality___{now}");
1464
1465 let script = RedisScripts::load_is_rate_limited();
1466
1467 let invoke_async = async |dims: &str, conn: &mut _| {
1469 let mut invocation = script.prepare_invoke();
1470 invocation
1471 .key(&key) .key(&refund_key) .arg(100) .arg(now + 60) .arg(1) .arg(false) .arg(dims) .arg(2); invocation
1481 .invoke_async::<ScriptResult>(conn)
1482 .await
1483 .unwrap()
1484 .0
1485 };
1486
1487 assert!(!invoke_async(":2:a", &mut conn).await[0].is_rejected);
1489 assert!(!invoke_async(":2:b", &mut conn).await[0].is_rejected);
1490
1491 let result = invoke_async(":2:c", &mut conn).await;
1493 assert!(result[0].is_rejected);
1494 assert_eq!(result[0].consumed, 0);
1495
1496 assert!(!invoke_async(":2:a", &mut conn).await[0].is_rejected);
1498 assert!(!invoke_async(":2:b", &mut conn).await[0].is_rejected);
1499
1500 let mut fields: Vec<(String, i64)> = conn.hgetall(&key).await.unwrap();
1502 fields.sort();
1503 assert_eq!(fields, vec![(":2:a".to_owned(), 2), (":2:b".to_owned(), 2)]);
1504 }
1505
1506 #[tokio::test]
1508 async fn test_is_rate_limited_script_dimensions_cardinality_edges() {
1509 let now = SystemTime::now()
1510 .duration_since(UNIX_EPOCH)
1511 .map(|duration| duration.as_secs())
1512 .unwrap();
1513
1514 let rate_limiter = build_rate_limiter();
1515 let mut conn = rate_limiter.client.get_connection().await.unwrap();
1516
1517 let script = RedisScripts::load_is_rate_limited();
1518
1519 let invoke_async = async |key: &str, dims: &str, cardinality: i32, conn: &mut _| {
1520 let mut invocation = script.prepare_invoke();
1521 invocation
1522 .key(key) .key(get_refunded_quota_key(key)) .arg(100) .arg(now + 60) .arg(1) .arg(false) .arg(dims) .arg(cardinality); invocation
1532 .invoke_async::<ScriptResult>(conn)
1533 .await
1534 .unwrap()
1535 .0
1536 };
1537
1538 let zero = format!("cardinality_zero___{now}");
1539 assert!(invoke_async(&zero, ":2:a", 0, &mut conn).await[0].is_rejected);
1540 assert_eq!(conn.hlen::<_, u64>(&zero).await.unwrap(), 0);
1541
1542 let unlimited = format!("cardinality_unlimited___{now}");
1544 for i in 0..10 {
1545 let dims = format!(":2:{i}");
1546 assert!(!invoke_async(&unlimited, &dims, -1, &mut conn).await[0].is_rejected);
1547 }
1548 assert_eq!(conn.hlen::<_, u64>(&unlimited).await.unwrap(), 10);
1549 }
1550
1551 #[tokio::test]
1553 async fn test_is_rate_limited_script_dimensions_share_refund_key() {
1554 let now = SystemTime::now()
1555 .duration_since(UNIX_EPOCH)
1556 .map(|duration| duration.as_secs())
1557 .unwrap();
1558
1559 let rate_limiter = build_rate_limiter();
1560 let mut conn = rate_limiter.client.get_connection().await.unwrap();
1561
1562 let key = format!("dims_refund___{now}");
1563 let refund_key = get_refunded_quota_key(&key);
1564 let () = conn.set(&refund_key, 5).await.unwrap();
1565
1566 let script = RedisScripts::load_is_rate_limited();
1567
1568 let invoke_async = async |dims: &str, conn: &mut _| {
1569 let mut invocation = script.prepare_invoke();
1570 invocation
1571 .key(&key) .key(&refund_key) .arg(1) .arg(now + 60) .arg(1) .arg(false) .arg(dims) .arg(999); invocation
1581 .invoke_async::<ScriptResult>(conn)
1582 .await
1583 .unwrap()
1584 .0
1585 };
1586
1587 let result = invoke_async(":2:a", &mut conn).await;
1589 assert!(!result[0].is_rejected);
1590 assert_eq!(result[0].consumed, -4);
1591
1592 let result = invoke_async(":2:b", &mut conn).await;
1593 assert!(!result[0].is_rejected);
1594 assert_eq!(result[0].consumed, -4);
1595 }
1596
1597 #[tokio::test]
1599 async fn test_quota_with_cache() {
1600 let quotas = &[Quota {
1601 id: Some(format!("test_simple_quota_{}", uuid::Uuid::new_v4()).into()),
1602 categories: DataCategories::new(),
1603 scope: QuotaScope::Organization,
1604 scope_id: None,
1605 limit: Some(50),
1606 window: Some(60),
1607 reason_code: Some(ReasonCode::new("get_lost")),
1608 namespace: None,
1609 group_by: None,
1610 }];
1611
1612 let scoping = ItemScoping {
1613 category: DataCategory::Error,
1614 scoping: Scoping {
1615 organization_id: OrganizationId::new(42),
1616 project_id: ProjectId::new(43),
1617 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1618 key_id: Some(44),
1619 },
1620 namespace: MetricNamespaceScoping::None,
1621 dimensions: DimensionMap::default(),
1622 };
1623
1624 let rate_limiter = build_rate_limiter().cache(Some(0.1), Some(0.9));
1627
1628 for _ in 0..50 {
1629 let rate_limits = rate_limiter
1630 .is_rate_limited(quotas, &scoping, 1, false)
1631 .await
1632 .unwrap();
1633
1634 assert!(rate_limits.is_empty());
1635 }
1636
1637 let rate_limits: Vec<RateLimit> = rate_limiter
1638 .is_rate_limited(quotas, &scoping, 1, false)
1639 .await
1640 .expect("rate limiting failed")
1641 .into_iter()
1642 .collect();
1643
1644 assert_eq!(
1645 rate_limits,
1646 vec![RateLimit {
1647 categories: DataCategories::new(),
1648 scope: RateLimitScope::Organization(OrganizationId::new(42)),
1649 reason_code: Some(ReasonCode::new("get_lost")),
1650 retry_after: rate_limits[0].retry_after,
1651 namespaces: smallvec![],
1652 dimensional: false,
1653 }]
1654 );
1655 }
1656
1657 #[tokio::test]
1658 async fn test_quota_with_cache_slightly_over_account() {
1659 let window = 60;
1660 let limit = 50 * window;
1661
1662 let quotas = &[Quota {
1663 id: Some(format!("test_simple_quota_{}", uuid::Uuid::new_v4()).into()),
1664 categories: DataCategories::new(),
1665 scope: QuotaScope::Organization,
1666 scope_id: None,
1667 limit: Some(limit),
1668 window: Some(window),
1669 reason_code: Some(ReasonCode::new("get_lost")),
1670 namespace: None,
1671 group_by: None,
1672 }];
1673
1674 let scoping = ItemScoping {
1675 category: DataCategory::Error,
1676 scoping: Scoping {
1677 organization_id: OrganizationId::new(42),
1678 project_id: ProjectId::new(43),
1679 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1680 key_id: Some(44),
1681 },
1682 namespace: MetricNamespaceScoping::None,
1683 dimensions: DimensionMap::default(),
1684 };
1685
1686 let rate_limiter1 = build_rate_limiter().cache(Some(0.1), None);
1688 let rate_limiter2 = build_rate_limiter().cache(Some(0.1), None);
1689
1690 let rate_limits = rate_limiter1
1692 .is_rate_limited(quotas, &scoping, 1, false)
1693 .await
1694 .unwrap();
1695 assert!(rate_limits.is_empty());
1696 let rate_limits = rate_limiter1
1698 .is_rate_limited(quotas, &scoping, 3, false)
1699 .await
1700 .unwrap();
1701 assert!(rate_limits.is_empty());
1702
1703 let rate_limits = rate_limiter2
1705 .is_rate_limited(quotas, &scoping, limit as usize - 1, false)
1706 .await
1707 .unwrap();
1708 assert!(rate_limits.is_empty());
1709
1710 let rate_limits = rate_limiter1
1712 .is_rate_limited(quotas, &scoping, 1, false)
1713 .await
1714 .unwrap();
1715 assert!(rate_limits.is_empty());
1716
1717 let rate_limits: Vec<RateLimit> = rate_limiter1
1719 .is_rate_limited(quotas, &scoping, 1, false)
1720 .await
1721 .unwrap()
1722 .into_iter()
1723 .collect();
1724
1725 assert_eq!(
1726 rate_limits,
1727 vec![RateLimit {
1728 categories: DataCategories::new(),
1729 scope: RateLimitScope::Organization(OrganizationId::new(42)),
1730 reason_code: Some(ReasonCode::new("get_lost")),
1731 retry_after: rate_limits[0].retry_after,
1732 namespaces: smallvec![],
1733 dimensional: false,
1734 }]
1735 );
1736 }
1737
1738 #[tokio::test]
1739 async fn test_quota_dimensions() {
1740 let quotas = &[
1741 Quota {
1742 id: Some(format!("test_quota_go_over{}", uuid::Uuid::new_v4()).into()),
1743 categories: DataCategories::new().add(DataCategory::Monitor).unwrap(),
1744 scope: QuotaScope::Project,
1745 scope_id: None,
1746 limit: Some(2),
1747 window: Some(60),
1748 reason_code: Some(ReasonCode::new("get_lost")),
1749 namespace: None,
1750 group_by: GroupBy {
1751 max_cardinality: 999,
1752 dimensions: BTreeSet::from([
1753 Dimension::CheckInEnvironment,
1754 Dimension::CheckInSlug,
1755 ])
1756 .into(),
1757 }
1758 .into(),
1759 },
1760 Quota {
1761 id: Some(format!("test_quota_wont_go_over{}", uuid::Uuid::new_v4()).into()),
1762 categories: DataCategories::new().add(DataCategory::Monitor).unwrap(),
1763 scope: QuotaScope::Project,
1764 scope_id: None,
1765 limit: Some(2),
1766 window: Some(60),
1767 reason_code: Some(ReasonCode::new("get_lost")),
1768 namespace: None,
1769 group_by: None,
1770 },
1771 ];
1772
1773 let scoping = ItemScoping {
1774 category: DataCategory::Monitor,
1775 scoping: Scoping {
1776 organization_id: OrganizationId::new(42),
1777 project_id: ProjectId::new(43),
1778 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1779 key_id: Some(44),
1780 },
1781 namespace: MetricNamespaceScoping::None,
1782 dimensions: DimensionMap::from([
1783 (Dimension::CheckInEnvironment, "us".to_owned()),
1784 (Dimension::CheckInSlug, "cron1".to_owned()),
1785 ]),
1786 };
1787
1788 let no_dims_scoping = ItemScoping {
1789 category: DataCategory::Monitor,
1790 scoping: Scoping {
1791 organization_id: OrganizationId::new(42),
1792 project_id: ProjectId::new(43),
1793 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1794 key_id: Some(44),
1795 },
1796 namespace: MetricNamespaceScoping::None,
1797 dimensions: DimensionMap::default(),
1798 };
1799
1800 let rate_limiter = build_rate_limiter();
1801
1802 let is_limited = rate_limiter
1804 .is_rate_limited(quotas, &scoping, 1, true)
1805 .await
1806 .unwrap()
1807 .is_limited();
1808 assert!(!is_limited);
1809
1810 let is_limited = rate_limiter
1811 .is_rate_limited(quotas, &scoping, 2, false)
1812 .await
1813 .unwrap()
1814 .is_limited();
1815 assert!(is_limited);
1816
1817 let is_limited = rate_limiter
1819 .is_rate_limited(quotas, &no_dims_scoping, 1, true)
1820 .await
1821 .unwrap()
1822 .is_limited();
1823 assert!(!is_limited);
1824
1825 let is_limited = rate_limiter
1827 .is_rate_limited(quotas, &no_dims_scoping, 2, false)
1828 .await
1829 .unwrap()
1830 .is_limited();
1831 assert!(is_limited);
1832 }
1833
1834 fn monitor_quota(limit: u64, group_by: Option<GroupBy>) -> Quota {
1836 Quota {
1837 id: Some(format!("test_dimensions_{}", uuid::Uuid::new_v4()).into()),
1838 categories: DataCategories::new().add(DataCategory::Monitor).unwrap(),
1839 scope: QuotaScope::Project,
1840 scope_id: None,
1841 limit: Some(limit),
1842 window: Some(60),
1843 reason_code: Some(ReasonCode::new("get_lost")),
1844 namespace: None,
1845 group_by,
1846 }
1847 }
1848
1849 fn monitor_scoping(dimensions: &[(Dimension, &str)]) -> ItemScoping {
1851 let mut owned_dims: Vec<_> = dimensions
1852 .iter()
1853 .map(|(d, v)| (*d, (*v).to_owned()))
1854 .collect();
1855 ItemScoping {
1856 category: DataCategory::Monitor,
1857 scoping: Scoping {
1858 organization_id: OrganizationId::new(42),
1859 project_id: ProjectId::new(43),
1860 project_key: ProjectKey::parse("a94ae32be2584e0bbd7a4cbb95971fee").unwrap(),
1861 key_id: Some(44),
1862 },
1863 namespace: MetricNamespaceScoping::None,
1864 dimensions: DimensionMap::from_iter(owned_dims.drain(..)),
1865 }
1866 }
1867
1868 #[tokio::test]
1870 async fn test_quota_dimensions_are_independent_buckets() {
1871 let quotas = &[monitor_quota(
1872 1,
1873 GroupBy {
1874 max_cardinality: 999,
1875 dimensions: BTreeSet::from([Dimension::CheckInEnvironment, Dimension::CheckInSlug])
1876 .into(),
1877 }
1878 .into(),
1879 )];
1880
1881 let rate_limiter = build_rate_limiter();
1882
1883 let distinct = [
1886 [
1887 (Dimension::CheckInEnvironment, "prod"),
1888 (Dimension::CheckInSlug, "cron1"),
1889 ],
1890 [
1891 (Dimension::CheckInEnvironment, "prod"),
1892 (Dimension::CheckInSlug, "cron2"),
1893 ],
1894 [
1895 (Dimension::CheckInEnvironment, "dev"),
1896 (Dimension::CheckInSlug, "cron1"),
1897 ],
1898 ];
1899
1900 for dims in &distinct {
1901 let scoping = monitor_scoping(dims);
1902
1903 let rate_limits = rate_limiter
1904 .is_rate_limited(quotas, &scoping, 1, false)
1905 .await
1906 .unwrap();
1907 assert!(!rate_limits.is_limited(), "first check for {dims:?}");
1908
1909 let rate_limits: Vec<RateLimit> = rate_limiter
1911 .is_rate_limited(quotas, &scoping, 1, false)
1912 .await
1913 .unwrap()
1914 .into_iter()
1915 .collect();
1916
1917 assert_eq!(
1918 rate_limits,
1919 vec![RateLimit {
1920 categories: DataCategories::new().add(DataCategory::Monitor).unwrap(),
1921 scope: RateLimitScope::Project(ProjectId::new(43)),
1922 reason_code: Some(ReasonCode::new("get_lost")),
1923 retry_after: rate_limits[0].retry_after,
1924 namespaces: smallvec![],
1925 dimensional: true,
1926 }],
1927 "second check for {dims:?}"
1928 );
1929 }
1930 }
1931
1932 #[tokio::test]
1934 async fn test_quota_dimensions_ignores_unrelated_dimensions() {
1935 let quotas = &[monitor_quota(
1936 2,
1937 GroupBy {
1938 max_cardinality: 999,
1939 dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
1940 }
1941 .into(),
1942 )];
1943
1944 let rate_limiter = build_rate_limiter();
1945
1946 let prod = monitor_scoping(&[
1949 (Dimension::CheckInEnvironment, "prod"),
1950 (Dimension::CheckInSlug, "cron1"),
1951 ]);
1952 let dev = monitor_scoping(&[
1953 (Dimension::CheckInEnvironment, "dev"),
1954 (Dimension::CheckInSlug, "cron1"),
1955 ]);
1956
1957 assert!(
1958 !rate_limiter
1959 .is_rate_limited(quotas, &prod, 1, false)
1960 .await
1961 .unwrap()
1962 .is_limited()
1963 );
1964 assert!(
1965 !rate_limiter
1966 .is_rate_limited(quotas, &dev, 1, false)
1967 .await
1968 .unwrap()
1969 .is_limited()
1970 );
1971
1972 assert!(
1974 rate_limiter
1975 .is_rate_limited(quotas, &prod, 1, false)
1976 .await
1977 .unwrap()
1978 .is_limited()
1979 );
1980 assert!(
1981 rate_limiter
1982 .is_rate_limited(quotas, &dev, 1, false)
1983 .await
1984 .unwrap()
1985 .is_limited()
1986 );
1987 }
1988
1989 #[tokio::test]
1991 async fn test_quota_dimensions_skipped_for_undimensioned_item() {
1992 let quotas = &[monitor_quota(
1993 1,
1994 GroupBy {
1995 max_cardinality: 999,
1996 dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
1997 }
1998 .into(),
1999 )];
2000
2001 let rate_limiter = build_rate_limiter();
2002
2003 for _ in 0..5 {
2005 assert!(
2006 !rate_limiter
2007 .is_rate_limited(quotas, &monitor_scoping(&[]), 1, false)
2008 .await
2009 .unwrap()
2010 .is_limited()
2011 );
2012 }
2013
2014 let quotas = &[monitor_quota(
2016 1,
2017 GroupBy {
2018 max_cardinality: 999,
2019 dimensions: BTreeSet::from([Dimension::CheckInEnvironment, Dimension::CheckInSlug])
2020 .into(),
2021 }
2022 .into(),
2023 )];
2024
2025 for _ in 0..5 {
2026 assert!(
2027 !rate_limiter
2028 .is_rate_limited(
2029 quotas,
2030 &monitor_scoping(&[(Dimension::CheckInSlug, "cron1")]),
2031 1,
2032 false
2033 )
2034 .await
2035 .unwrap()
2036 .is_limited()
2037 );
2038 }
2039 }
2040
2041 #[tokio::test]
2044 async fn test_quota_dimensions_max_cardinality() {
2045 let quotas = &[monitor_quota(
2046 100,
2047 GroupBy {
2048 max_cardinality: 2,
2049 dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
2050 }
2051 .into(),
2052 )];
2053
2054 let rate_limiter = build_rate_limiter();
2055
2056 let cron1 = monitor_scoping(&[(Dimension::CheckInSlug, "cron1")]);
2057 let cron2 = monitor_scoping(&[(Dimension::CheckInSlug, "cron2")]);
2058 let cron3 = monitor_scoping(&[(Dimension::CheckInSlug, "cron3")]);
2059
2060 assert!(
2062 !rate_limiter
2063 .is_rate_limited(quotas, &cron1, 1, false)
2064 .await
2065 .unwrap()
2066 .is_limited()
2067 );
2068 assert!(
2069 !rate_limiter
2070 .is_rate_limited(quotas, &cron2, 1, false)
2071 .await
2072 .unwrap()
2073 .is_limited()
2074 );
2075
2076 let rate_limits: Vec<RateLimit> = rate_limiter
2078 .is_rate_limited(quotas, &cron3, 1, false)
2079 .await
2080 .unwrap()
2081 .into_iter()
2082 .collect();
2083
2084 assert_eq!(
2085 rate_limits,
2086 vec![RateLimit {
2087 categories: DataCategories::new().add(DataCategory::Monitor).unwrap(),
2088 scope: RateLimitScope::Project(ProjectId::new(43)),
2089 reason_code: Some(ReasonCode::new("get_lost")),
2090 retry_after: rate_limits[0].retry_after,
2091 namespaces: smallvec![],
2092 dimensional: true,
2093 }]
2094 );
2095
2096 assert!(
2098 !rate_limiter
2099 .is_rate_limited(quotas, &cron1, 1, false)
2100 .await
2101 .unwrap()
2102 .is_limited()
2103 );
2104 assert!(
2105 !rate_limiter
2106 .is_rate_limited(quotas, &cron2, 1, false)
2107 .await
2108 .unwrap()
2109 .is_limited()
2110 );
2111 }
2112
2113 #[tokio::test]
2115 async fn test_quota_dimensions_zero_cardinality() {
2116 let quotas = &[monitor_quota(
2117 100,
2118 GroupBy {
2119 max_cardinality: 0,
2120 dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
2121 }
2122 .into(),
2123 )];
2124
2125 let rate_limiter = build_rate_limiter();
2126
2127 assert!(
2128 rate_limiter
2129 .is_rate_limited(
2130 quotas,
2131 &monitor_scoping(&[(Dimension::CheckInSlug, "cron1")]),
2132 1,
2133 false
2134 )
2135 .await
2136 .unwrap()
2137 .is_limited()
2138 );
2139 }
2140
2141 #[tokio::test]
2147 async fn test_quota_dimensions_with_cache() {
2148 let limit = 50 * 60;
2149
2150 let quotas = &[monitor_quota(
2151 limit,
2152 GroupBy {
2153 max_cardinality: 999,
2154 dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
2155 }
2156 .into(),
2157 )];
2158
2159 let rate_limiter = build_rate_limiter().cache(Some(0.1), Some(0.9));
2160
2161 let crons = [
2162 monitor_scoping(&[(Dimension::CheckInSlug, "cron1")]),
2163 monitor_scoping(&[(Dimension::CheckInSlug, "cron2")]),
2164 ];
2165
2166 let mut accepted = [0u64; 2];
2169 for _ in 0..(limit + limit / 2) {
2170 for (scoping, accepted) in crons.iter().zip(accepted.iter_mut()) {
2171 if !rate_limiter
2172 .is_rate_limited(quotas, scoping, 1, false)
2173 .await
2174 .unwrap()
2175 .is_limited()
2176 {
2177 *accepted += 1;
2178 }
2179 }
2180 }
2181
2182 let tolerance = limit / 100;
2185 for accepted in accepted {
2186 assert!(
2187 accepted.abs_diff(limit) <= tolerance,
2188 "accepted {accepted} of a limit of {limit}"
2189 );
2190 }
2191 }
2192
2193 #[tokio::test]
2196 async fn test_quota_dimensions_alongside_undimensioned_quota() {
2197 let quotas = &[
2199 monitor_quota(
2200 1,
2201 GroupBy {
2202 max_cardinality: 999,
2203 dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
2204 }
2205 .into(),
2206 ),
2207 monitor_quota(3, None),
2208 ];
2209
2210 let rate_limiter = build_rate_limiter();
2211
2212 let cron1 = monitor_scoping(&[(Dimension::CheckInSlug, "cron1")]);
2213 let cron2 = monitor_scoping(&[(Dimension::CheckInSlug, "cron2")]);
2214 let cron3 = monitor_scoping(&[(Dimension::CheckInSlug, "cron3")]);
2215 let cron4 = monitor_scoping(&[(Dimension::CheckInSlug, "cron4")]);
2216
2217 for scoping in [&cron1, &cron2, &cron3] {
2220 assert!(
2221 !rate_limiter
2222 .is_rate_limited(quotas, scoping, 1, false)
2223 .await
2224 .unwrap()
2225 .is_limited()
2226 );
2227 }
2228
2229 assert!(
2231 rate_limiter
2232 .is_rate_limited(quotas, &cron1, 1, false)
2233 .await
2234 .unwrap()
2235 .is_limited()
2236 );
2237
2238 assert!(
2241 rate_limiter
2242 .is_rate_limited(quotas, &cron4, 1, false)
2243 .await
2244 .unwrap()
2245 .is_limited()
2246 );
2247 }
2248
2249 #[tokio::test]
2255 async fn test_quota_dimensions_are_not_cacheable() {
2256 let quotas = &[monitor_quota(
2257 1,
2258 GroupBy {
2259 max_cardinality: 999,
2260 dimensions: BTreeSet::from([Dimension::CheckInSlug]).into(),
2261 }
2262 .into(),
2263 )];
2264
2265 let rate_limiter = build_rate_limiter();
2266
2267 let cron1 = monitor_scoping(&[(Dimension::CheckInSlug, "cron1")]);
2268 let cron2 = monitor_scoping(&[(Dimension::CheckInSlug, "cron2")]);
2269
2270 rate_limiter
2272 .is_rate_limited(quotas, &cron1, 1, false)
2273 .await
2274 .unwrap();
2275 let limits = rate_limiter
2276 .is_rate_limited(quotas, &cron1, 1, false)
2277 .await
2278 .unwrap();
2279
2280 assert!(limits.is_limited());
2282
2283 assert!(limits.cacheable().is_empty());
2286 assert!(!limits.cacheable().check(&cron2).is_limited());
2287
2288 assert!(
2290 !rate_limiter
2291 .is_rate_limited(quotas, &cron2, 1, false)
2292 .await
2293 .unwrap()
2294 .is_limited()
2295 );
2296 }
2297
2298 #[tokio::test]
2300 async fn test_quota_without_dimensions_is_cacheable() {
2301 let quotas = &[monitor_quota(1, None)];
2302
2303 let rate_limiter = build_rate_limiter();
2304
2305 let cron1 = monitor_scoping(&[(Dimension::CheckInSlug, "cron1")]);
2306
2307 rate_limiter
2308 .is_rate_limited(quotas, &cron1, 1, false)
2309 .await
2310 .unwrap();
2311 let limits = rate_limiter
2312 .is_rate_limited(quotas, &cron1, 1, false)
2313 .await
2314 .unwrap();
2315
2316 assert!(limits.is_limited());
2319 assert!(!limits.cacheable().is_empty());
2320 }
2321}