mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-14 00:53:14 +00:00
feat(api): add opt-in per-bucket dimension to the S3 API rate limiter (#4949)
Follow-up to #4895 (backlog#1191 deferred sub-item). The client-IP dimension gives per-client fairness; this adds a collective per-bucket budget so one hot bucket cannot monopolize the server regardless of how many client IPs the traffic is spread across. - Generalize RateLimiter over its key type with a Borrow-based check() so &str lookups against String bucket keys stay allocation-free on the hit path; client-IP call sites are unchanged in behavior. - RateLimitLayer now carries optional client and bucket limiters; a request must pass every configured dimension, and rejections report which one tripped via a new 'dimension' metric label. - Bucket extraction mirrors s3s host routing: virtual-hosted-style resolves the Host/authority prefix against the same expanded domain set (with port variants) the s3s router uses; otherwise the first path segment. Admin and table-catalog namespaces are never buckets. - New env vars RUSTFS_API_RATE_LIMIT_BUCKET_RPM/_BURST (default 0 = dimension off) under the existing enable switch; bucket-only configurations (client RPM 0) are supported. - Bucket names are attacker-chosen, so the bounded-shards design (100k keys, lossless idle sweeps, most-idle eviction) is the memory defense; a test floods 10k random names and asserts the cap holds. - Unit tests for extraction, shared bucket budgets across IPs, both- dimensions interaction, and the extended env matrix; e2e test proves bucket-only throttling on the real server with an unrelated bucket unaffected.
This commit is contained in:
@@ -50,3 +50,26 @@ pub const ENV_API_RATE_LIMIT_BURST: &str = "RUSTFS_API_RATE_LIMIT_BURST";
|
||||
|
||||
/// Default for `RUSTFS_API_RATE_LIMIT_BURST` (`0` = same as RPM).
|
||||
pub const DEFAULT_API_RATE_LIMIT_BURST: u32 = 0;
|
||||
|
||||
/// Sustained S3 API request budget per addressed bucket, in requests per
|
||||
/// minute — a collective ceiling shared by all clients of that bucket.
|
||||
///
|
||||
/// Complements the per-client-IP dimension: it protects the server from one
|
||||
/// hot bucket regardless of how many client IPs the traffic comes from. `0`
|
||||
/// disables the bucket dimension. Requires `RUSTFS_API_RATE_LIMIT_ENABLE`.
|
||||
/// Environment variable: RUSTFS_API_RATE_LIMIT_BUCKET_RPM
|
||||
/// Example: RUSTFS_API_RATE_LIMIT_BUCKET_RPM=60000
|
||||
pub const ENV_API_RATE_LIMIT_BUCKET_RPM: &str = "RUSTFS_API_RATE_LIMIT_BUCKET_RPM";
|
||||
|
||||
/// Default for `RUSTFS_API_RATE_LIMIT_BUCKET_RPM` (`0` = dimension disabled).
|
||||
pub const DEFAULT_API_RATE_LIMIT_BUCKET_RPM: u32 = 0;
|
||||
|
||||
/// Burst capacity per bucket (maximum tokens in the bucket-dimension bucket).
|
||||
///
|
||||
/// `0` means "same as `RUSTFS_API_RATE_LIMIT_BUCKET_RPM`".
|
||||
/// Environment variable: RUSTFS_API_RATE_LIMIT_BUCKET_BURST
|
||||
/// Example: RUSTFS_API_RATE_LIMIT_BUCKET_BURST=2000
|
||||
pub const ENV_API_RATE_LIMIT_BUCKET_BURST: &str = "RUSTFS_API_RATE_LIMIT_BUCKET_BURST";
|
||||
|
||||
/// Default for `RUSTFS_API_RATE_LIMIT_BUCKET_BURST` (`0` = same as bucket RPM).
|
||||
pub const DEFAULT_API_RATE_LIMIT_BUCKET_BURST: u32 = 0;
|
||||
|
||||
@@ -86,6 +86,52 @@ async fn api_rate_limit_enforces_429_with_retry_after_when_enabled() -> TestResu
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn api_rate_limit_bucket_dimension_throttles_per_bucket() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
// Bucket dimension only: the readiness-poll ListBuckets calls hit "/"
|
||||
// (no bucket) and therefore do not consume any budget.
|
||||
env.start_rustfs_server_with_env(
|
||||
vec![],
|
||||
&[
|
||||
("RUSTFS_API_RATE_LIMIT_ENABLE", "true"),
|
||||
("RUSTFS_API_RATE_LIMIT_BUCKET_RPM", "60"),
|
||||
("RUSTFS_API_RATE_LIMIT_BUCKET_BURST", "5"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let client = local_http_client();
|
||||
|
||||
// Unauthenticated GETs are still counted arrivals (403, not 429, while
|
||||
// within budget); the sixth rapid hit on the same bucket must throttle.
|
||||
let mut throttled = false;
|
||||
for i in 0..6 {
|
||||
let response = client.get(format!("{}/hot-bucket/object-{i}", env.url)).send().await?;
|
||||
if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
throttled = true;
|
||||
assert!(
|
||||
response.headers().contains_key(reqwest::header::RETRY_AFTER),
|
||||
"bucket-dimension 429 must carry Retry-After"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(throttled, "6 rapid requests against burst 5 must trip the bucket dimension");
|
||||
|
||||
// A different bucket has its own budget.
|
||||
let other = client.get(format!("{}/cold-bucket/object", env.url)).send().await?;
|
||||
assert_ne!(
|
||||
other.status(),
|
||||
reqwest::StatusCode::TOO_MANY_REQUESTS,
|
||||
"an unrelated bucket must not be throttled"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn api_rate_limit_stays_inert_by_default() -> TestResult {
|
||||
|
||||
Reference in New Issue
Block a user