Files
buzz/crates/buzz-pubsub/src/rate_limiter.rs
Tyler 14fba21e57 Multi-tenant Buzz relay: community_id as a server-resolved key (comprehensive rewrite) (#1321)
Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
Signed-off-by: npub1jh9wn95s0472h86ahapupaf7m6kx4v9sx2n0atj2hltcfer8k06s5n3pyf <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Tyler Longwell <tlongwell@block.xyz>
Signed-off-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: npub17jjz49l9jjmhhk7cac63j8yt9z555n9cw8vk7v5jz4vzw4ppld5qgj57cc <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Eva <011987e296fd5006292d2f930b574be47c7801048d1983c46c425d3c95f0cffd@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Mari <95cae996907d7cab9f5dbf43c0f53edeac6ab0b032a6feae4abfd784e467b3f5@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Sami <f4a42a97e594b77bdbd8ee35191c8b28a94a4cb871d96f32921558275421fb68@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Max <d8473ee32b973aa31a21a65adddcc4b69cc2a8a4dee8121ecd51926e0cddbc02@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Quinn <96f056ad5f2305c8ddf637dc65d048aa4c12d7daeb8867690e34fca46b0ef64c@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Dawn <c6237ef84fa537c78dcee78efd2d4e59f728859c7f194da42ac51ededfa0be05@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Tyler Longwell <tlongwell@block.xyz>
Co-authored-by: Sami <sami@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: npub1t2tgm7d8f995uqvmnm8h88sg3wnpp9a5xysjf6dg3tjmgt3ltulqdp8ehr <5a968df9a7494b4e019b9ecf739e088ba61097b4312124e9a88ae5b42e3f5f3e@sprout-oss.stage.blox.sqprod.co>
2026-06-29 12:39:02 -04:00

122 lines
3.9 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Redis-backed rate limiter using atomic Lua script (INCR + EXPIRE).
//!
//! Implements the [`RateLimiter`] trait from `buzz-auth`.
//! Uses a single Lua script to atomically INCR and conditionally EXPIRE,
//! eliminating the crash window where a key could exist without a TTL.
//!
//! ⚠️ Fixed windows allow up to 2× burst at boundaries. Upgrade to sliding
//! window or token bucket for strict limiting.
use std::net::IpAddr;
use buzz_auth::{
error::AuthError,
rate_limit::{LimitType, RateLimitResult, RateLimiter},
};
use buzz_core::TenantContext;
use nostr::PublicKey;
use redis::Script;
/// Atomically INCR the key, set EXPIRE on first call, and return (count, ttl).
///
/// Using a Lua script ensures INCR and EXPIRE are executed atomically —
/// a crash between them can no longer leave a key without a TTL.
const RATE_LIMIT_SCRIPT: &str = r#"
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
local ttl = redis.call('TTL', KEYS[1])
return {count, ttl}
"#;
/// Run the atomic rate-limit Lua script against `key` and return a
/// [`RateLimitResult`].
///
/// If the TTL comes back negative (key exists without expiry — broken state
/// from a prior crash), the key is repaired with a fresh EXPIRE and a warning
/// is logged.
async fn run_rate_limit(
pool: &deadpool_redis::Pool,
key: &str,
window_secs: u64,
limit: u64,
) -> Result<RateLimitResult, AuthError> {
let mut conn = pool
.get()
.await
.map_err(|e| AuthError::Internal(format!("Redis pool: {e}")))?;
let script = Script::new(RATE_LIMIT_SCRIPT);
let (count, ttl): (u64, i64) = script
.key(key)
.arg(window_secs as i64)
.invoke_async(&mut *conn)
.await
.map_err(|e| AuthError::Internal(format!("Redis rate limit script: {e}")))?;
// ttl == -1 means the key exists but has no expiry — broken state from a
// prior crash between INCR and EXPIRE. Repair it now.
let reset_in_secs = if ttl < 0 {
tracing::warn!(key = %key, "rate limit key has no TTL — repairing");
let _: () = redis::cmd("EXPIRE")
.arg(key)
.arg(window_secs as i64)
.query_async(&mut *conn)
.await
.map_err(|e| AuthError::Internal(format!("Redis EXPIRE repair: {e}")))?;
// After repair, the window resets to the full duration.
window_secs
} else {
ttl.max(0) as u64
};
if count <= limit {
Ok(RateLimitResult::allowed(count, limit, reset_in_secs))
} else {
Ok(RateLimitResult::denied(count, limit, reset_in_secs))
}
}
/// Redis-backed rate limiter using fixed-window counters.
///
/// Pubkey keys are community-scoped via `&TenantContext`:
/// `buzz:{community}:ratelimit:{pubkey_hex}:{suffix}`. IP keys remain
/// operator-global: `buzz:ratelimit:ip:{ip}:conn`. The counter and its TTL are
/// managed atomically via a Lua script to prevent keys from persisting without
/// expiry.
pub struct RedisRateLimiter {
pool: deadpool_redis::Pool,
}
impl RedisRateLimiter {
/// Create a new `RedisRateLimiter` backed by the given connection pool.
pub fn new(pool: deadpool_redis::Pool) -> Self {
Self { pool }
}
}
impl RateLimiter for RedisRateLimiter {
async fn check_and_increment(
&self,
ctx: &TenantContext,
pubkey: &PublicKey,
limit_type: LimitType,
window_secs: u64,
limit: u64,
) -> Result<RateLimitResult, AuthError> {
let key = buzz_auth::rate_limit::rate_limit_key(ctx, pubkey, &limit_type);
run_rate_limit(&self.pool, &key, window_secs, limit).await
}
async fn check_ip_connection(
&self,
ip: &IpAddr,
window_secs: u64,
limit: u64,
) -> Result<RateLimitResult, AuthError> {
let key = buzz_auth::rate_limit::ip_rate_limit_key(ip);
run_rate_limit(&self.pool, &key, window_secs, limit).await
}
}