-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add support for dynamic rate limit configurations with hot reload #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: pr3-base
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
@@ -1,14 +1,17 @@ | ||||||
use std::collections::HashMap; | ||||||
use std::net::IpAddr; | ||||||
use std::num::NonZeroU32; | ||||||
use std::sync::Arc; | ||||||
use std::{collections::HashMap, sync::RwLock}; | ||||||
|
||||||
use futures_util::future::BoxFuture; | ||||||
use futures_util::FutureExt; | ||||||
use governor::Quota; | ||||||
use grafbase_telemetry::span::GRAFBASE_TARGET; | ||||||
use serde_json::Value; | ||||||
|
||||||
use http::{HeaderName, HeaderValue}; | ||||||
use runtime::rate_limiting::{Error, GraphRateLimit, KeyedRateLimitConfig, RateLimiter, RateLimiterContext}; | ||||||
use tokio::sync::mpsc; | ||||||
|
||||||
pub struct RateLimitingContext(pub String); | ||||||
|
||||||
|
@@ -34,48 +37,72 @@ impl RateLimiterContext for RateLimitingContext { | |||||
} | ||||||
} | ||||||
|
||||||
#[derive(Default)] | ||||||
pub struct InMemoryRateLimiter { | ||||||
inner: HashMap<String, governor::DefaultKeyedRateLimiter<usize>>, | ||||||
limiters: Arc<RwLock<HashMap<String, governor::DefaultKeyedRateLimiter<usize>>>>, | ||||||
} | ||||||
|
||||||
impl InMemoryRateLimiter { | ||||||
pub fn runtime(config: KeyedRateLimitConfig<'_>) -> RateLimiter { | ||||||
let mut limiter = Self::default(); | ||||||
pub fn runtime( | ||||||
config: KeyedRateLimitConfig, | ||||||
mut updates: mpsc::Receiver<HashMap<String, GraphRateLimit>>, | ||||||
) -> RateLimiter { | ||||||
let mut limiters = HashMap::new(); | ||||||
|
||||||
// add subgraph rate limiting configuration | ||||||
for (name, rate_limit_config) in config.rate_limiting_configs { | ||||||
limiter = limiter.with_rate_limiter(name, rate_limit_config); | ||||||
for (name, config) in config.rate_limiting_configs { | ||||||
let Some(limiter) = create_limiter(config) else { | ||||||
continue; | ||||||
}; | ||||||
|
||||||
limiters.insert(name.to_string(), limiter); | ||||||
} | ||||||
|
||||||
RateLimiter::new(limiter) | ||||||
} | ||||||
let limiters = Arc::new(RwLock::new(limiters)); | ||||||
let limiters_copy = limiters.clone(); | ||||||
|
||||||
tokio::spawn(async move { | ||||||
while let Some(updates) = updates.recv().await { | ||||||
let mut limiters = limiters_copy.write().unwrap(); | ||||||
|
||||||
for (name, config) in updates { | ||||||
let Some(limiter) = create_limiter(config) else { | ||||||
continue; | ||||||
}; | ||||||
|
||||||
limiters.insert(name.to_string(), limiter); | ||||||
} | ||||||
} | ||||||
}); | ||||||
|
||||||
pub fn with_rate_limiter(mut self, key: &str, rate_limit_config: GraphRateLimit) -> Self { | ||||||
let quota = (rate_limit_config.limit as u64) | ||||||
.checked_div(rate_limit_config.duration.as_secs()) | ||||||
.expect("rate limiter with invalid per second quota"); | ||||||
|
||||||
self.inner.insert( | ||||||
key.to_string(), | ||||||
governor::RateLimiter::keyed(Quota::per_second( | ||||||
NonZeroU32::new(quota as u32).expect("rate limit duration cannot be 0"), | ||||||
)), | ||||||
); | ||||||
self | ||||||
RateLimiter::new(Self { limiters }) | ||||||
} | ||||||
} | ||||||
|
||||||
fn create_limiter(rate_limit_config: GraphRateLimit) -> Option<governor::DefaultKeyedRateLimiter<usize>> { | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🐛 Bug
Suggested change
|
||||||
let Some(quota) = (rate_limit_config.limit as u64).checked_div(rate_limit_config.duration.as_secs()) else { | ||||||
tracing::error!(target: GRAFBASE_TARGET, "the duration for rate limit cannot be zero"); | ||||||
return None; | ||||||
}; | ||||||
|
||||||
let Some(quota) = NonZeroU32::new(quota as u32) else { | ||||||
tracing::error!(target: GRAFBASE_TARGET, "the limit is too low per defined duration"); | ||||||
return None; | ||||||
}; | ||||||
|
||||||
Some(governor::RateLimiter::keyed(Quota::per_second(quota))) | ||||||
} | ||||||
|
||||||
impl runtime::rate_limiting::RateLimiterInner for InMemoryRateLimiter { | ||||||
fn limit<'a>(&'a self, context: &'a dyn RateLimiterContext) -> BoxFuture<'a, Result<(), Error>> { | ||||||
async { | ||||||
if let Some(key) = context.key() { | ||||||
if let Some(rate_limiter) = self.inner.get(key) { | ||||||
rate_limiter | ||||||
.check_key(&usize::MIN) | ||||||
.map_err(|_err| Error::ExceededCapacity)?; | ||||||
}; | ||||||
} | ||||||
let Some(key) = context.key() else { return Ok(()) }; | ||||||
let limiters = self.limiters.read().unwrap(); | ||||||
|
||||||
if let Some(rate_limiter) = limiters.get(key) { | ||||||
rate_limiter | ||||||
.check_key(&usize::MIN) | ||||||
.map_err(|_err| Error::ExceededCapacity)?; | ||||||
}; | ||||||
|
||||||
Ok(()) | ||||||
} | ||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -15,6 +15,7 @@ use futures_util::future::BoxFuture; | |
use grafbase_telemetry::span::GRAFBASE_TARGET; | ||
use redis::ClientTlsConfig; | ||
use runtime::rate_limiting::{Error, GraphRateLimit, RateLimiter, RateLimiterContext}; | ||
use tokio::sync::watch; | ||
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] | ||
pub struct RateLimitRedisConfig<'a> { | ||
|
@@ -30,6 +31,8 @@ pub struct RateLimitRedisTlsConfig<'a> { | |
pub ca: Option<&'a Path>, | ||
} | ||
|
||
pub type Limits = watch::Receiver<HashMap<String, GraphRateLimit>>; | ||
|
||
/// Rate limiter by utilizing Redis as a backend. It uses a averaging fixed window algorithm | ||
/// to define is the limit reached or not. | ||
/// | ||
|
@@ -47,7 +50,7 @@ pub struct RateLimitRedisTlsConfig<'a> { | |
pub struct RedisRateLimiter { | ||
pool: Pool<pool::Manager>, | ||
key_prefix: String, | ||
subgraph_limits: HashMap<String, GraphRateLimit>, | ||
limits: Limits, | ||
} | ||
|
||
enum Key<'a> { | ||
|
@@ -69,18 +72,11 @@ impl<'a> fmt::Display for Key<'a> { | |
} | ||
|
||
impl RedisRateLimiter { | ||
pub async fn runtime( | ||
config: RateLimitRedisConfig<'_>, | ||
subgraph_limits: impl IntoIterator<Item = (&str, GraphRateLimit)>, | ||
) -> anyhow::Result<RateLimiter> { | ||
let inner = Self::new(config, subgraph_limits).await?; | ||
Ok(RateLimiter::new(inner)) | ||
pub async fn runtime(config: RateLimitRedisConfig<'_>, limits: Limits) -> anyhow::Result<RateLimiter> { | ||
Ok(RateLimiter::new(Self::new(config, limits).await?)) | ||
} | ||
|
||
pub async fn new( | ||
config: RateLimitRedisConfig<'_>, | ||
subgraph_limits: impl IntoIterator<Item = (&str, GraphRateLimit)>, | ||
) -> anyhow::Result<RedisRateLimiter> { | ||
pub async fn new(config: RateLimitRedisConfig<'_>, limits: Limits) -> anyhow::Result<RedisRateLimiter> { | ||
let tls_config = match config.tls { | ||
Some(tls) => { | ||
let client_tls = match tls.cert.zip(tls.key) { | ||
|
@@ -144,15 +140,10 @@ impl RedisRateLimiter { | |
} | ||
}; | ||
|
||
let subgraph_limits = subgraph_limits | ||
.into_iter() | ||
.map(|(key, value)| (key.to_string(), value)) | ||
.collect(); | ||
|
||
Ok(Self { | ||
pool, | ||
key_prefix: config.key_prefix.to_string(), | ||
subgraph_limits, | ||
limits, | ||
}) | ||
} | ||
|
||
|
@@ -167,7 +158,7 @@ impl RedisRateLimiter { | |
async fn limit_inner(&self, context: &dyn RateLimiterContext) -> Result<(), Error> { | ||
let Some(key) = context.key() else { return Ok(()) }; | ||
|
||
let Some(config) = self.subgraph_limits.get(key) else { | ||
let Some(config) = self.limits.borrow().get(key).copied() else { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ⚡ Suggestion |
||
return Ok(()); | ||
}; | ||
|
||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Performance
Using
unwrap()
on the result ofRwLock::write()
can lead to a panic if the lock is poisoned. Consider handling the error gracefully instead.