mirror of
https://github.com/rustfs/rustfs.git
synced 2026-08-20 11:32:19 +00:00
feat(api): wire opt-in per-client S3 API rate limiting (429 + Retry-After) (#4895)
feat(api): wire opt-in per-client S3 API rate limiting (backlog#1191) RustFS shipped three rate-limiter implementations and none was wired to any request path: the tower layer never returned 429 (its over-limit branch passed requests through) and was never instantiated, the console env switches only logged, and the Swift token bucket was never called. Replace them with one working, default-off implementation: - Rewrite rustfs/src/server/rate_limit.rs as a sharded per-client-IP token-bucket limiter (32 mutex shards instead of one global RwLock write per request), bounded at 100k tracked IPs with lossless refilled-idle sweeps, returning 429 + Retry-After + x-ratelimit-* headers and an S3-style XML body. - Key on trusted-proxy-validated ClientInfo.real_ip, else the socket peer address; never read spoofable X-Forwarded-For/X-Real-IP headers. Requests without a resolvable identity fail open. The echoed request id is charset-gated to prevent reflected XML injection. - Wire the layer once at startup via option_layer between CatchPanicLayer and ReadinessGateLayer (external stack only), gated by new RUSTFS_API_RATE_LIMIT_ENABLE/_RPM/_BURST constants; health and profiling probes, internode RPC/gRPC, and the console are exempt. - Make RUSTFS_CONSOLE_RATE_LIMIT_ENABLE/_RPM actually enforce by reusing the same limiter core through an axum middleware. - Delete the dead Swift ratelimit module, its isolated tests, and the stale logging-guardrail entry; keep the live SwiftError 429 mapping. - Add unit tests (exhaustion/recovery with injected time, concurrency, cap eviction, spoofed-header and fail-open behavior, env matrix) and e2e tests proving 429 + Retry-After on the real server and zero behavior change with default configuration.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
/// Enable or disable per-client rate limiting for the S3 API.
|
||||
///
|
||||
/// When enabled (and `RUSTFS_API_RATE_LIMIT_RPM` > 0), requests are throttled
|
||||
/// per client IP using a token bucket; over-limit requests receive
|
||||
/// `429 Too Many Requests` with a `Retry-After` header. Internode RPC/gRPC,
|
||||
/// health probes, and the console (which has its own limiter) are exempt.
|
||||
/// Environment variable: RUSTFS_API_RATE_LIMIT_ENABLE
|
||||
/// Example: RUSTFS_API_RATE_LIMIT_ENABLE=true
|
||||
pub const ENV_API_RATE_LIMIT_ENABLE: &str = "RUSTFS_API_RATE_LIMIT_ENABLE";
|
||||
|
||||
/// Default for `RUSTFS_API_RATE_LIMIT_ENABLE`.
|
||||
///
|
||||
/// Disabled by default: RustFS ships permissive and operators opt in to
|
||||
/// abuse-protection hardening. When disabled the request path is unchanged.
|
||||
pub const DEFAULT_API_RATE_LIMIT_ENABLE: bool = false;
|
||||
|
||||
/// Sustained S3 API request budget per client IP, in requests per minute.
|
||||
///
|
||||
/// `0` means unlimited (rate limiting stays inert even when enabled).
|
||||
/// Environment variable: RUSTFS_API_RATE_LIMIT_RPM
|
||||
/// Example: RUSTFS_API_RATE_LIMIT_RPM=6000
|
||||
pub const ENV_API_RATE_LIMIT_RPM: &str = "RUSTFS_API_RATE_LIMIT_RPM";
|
||||
|
||||
/// Default for `RUSTFS_API_RATE_LIMIT_RPM`.
|
||||
///
|
||||
/// `0` (unlimited) so that setting only the enable switch cannot throttle
|
||||
/// traffic by surprise; operators must choose an explicit budget.
|
||||
pub const DEFAULT_API_RATE_LIMIT_RPM: u32 = 0;
|
||||
|
||||
/// Burst capacity per client IP (maximum tokens in the bucket).
|
||||
///
|
||||
/// Allows short spikes above the sustained rate. `0` means "same as RPM".
|
||||
/// Environment variable: RUSTFS_API_RATE_LIMIT_BURST
|
||||
/// Example: RUSTFS_API_RATE_LIMIT_BURST=200
|
||||
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;
|
||||
@@ -12,6 +12,7 @@
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
pub(crate) mod api;
|
||||
pub(crate) mod app;
|
||||
pub(crate) mod body_limits;
|
||||
pub(crate) mod capacity;
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
#[cfg(feature = "constants")]
|
||||
pub mod constants;
|
||||
#[cfg(feature = "constants")]
|
||||
pub use constants::api::*;
|
||||
#[cfg(feature = "constants")]
|
||||
pub use constants::app::*;
|
||||
#[cfg(feature = "constants")]
|
||||
pub use constants::body_limits::*;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! E2E coverage for the opt-in per-client S3 API rate limit (backlog#1191):
|
||||
//! the layer must be wired into the real server stack, reject over-limit
|
||||
//! clients with `429` + `Retry-After`, keep health probes exempt, and stay
|
||||
//! completely inert with default configuration.
|
||||
|
||||
use crate::common::{RustFSTestEnvironment, init_logging, local_http_client};
|
||||
use serial_test::serial;
|
||||
use tracing::info;
|
||||
|
||||
type TestResult = Result<(), Box<dyn std::error::Error + Send + Sync>>;
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn api_rate_limit_enforces_429_with_retry_after_when_enabled() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
// Burst 50 leaves headroom for the readiness-poll ListBuckets calls that
|
||||
// share the loopback client IP; refill (60 rpm = 1/s) is slow enough that
|
||||
// a rapid burst below reliably exhausts the bucket.
|
||||
env.start_rustfs_server_with_env(
|
||||
vec![],
|
||||
&[
|
||||
("RUSTFS_API_RATE_LIMIT_ENABLE", "true"),
|
||||
("RUSTFS_API_RATE_LIMIT_RPM", "60"),
|
||||
("RUSTFS_API_RATE_LIMIT_BURST", "50"),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
let client = local_http_client();
|
||||
let list_buckets_url = format!("{}/", env.url);
|
||||
|
||||
let mut throttled = None;
|
||||
let mut allowed = 0usize;
|
||||
for _ in 0..80 {
|
||||
let response = client.get(&list_buckets_url).send().await?;
|
||||
if response.status() == reqwest::StatusCode::TOO_MANY_REQUESTS {
|
||||
throttled = Some(response);
|
||||
break;
|
||||
}
|
||||
allowed += 1;
|
||||
}
|
||||
|
||||
let throttled = throttled.unwrap_or_else(|| panic!("no 429 within 80 rapid requests ({allowed} allowed) at burst 50"));
|
||||
assert!(allowed > 0, "healthy traffic below the burst must not be throttled");
|
||||
info!("rate limit engaged after {allowed} allowed requests");
|
||||
|
||||
let retry_after = throttled
|
||||
.headers()
|
||||
.get(reqwest::header::RETRY_AFTER)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.parse::<u64>().ok())
|
||||
.expect("429 must carry a numeric Retry-After header");
|
||||
assert!(retry_after >= 1, "Retry-After must be at least one second, got {retry_after}");
|
||||
assert_eq!(
|
||||
throttled.headers().get("x-ratelimit-limit").and_then(|v| v.to_str().ok()),
|
||||
Some("60"),
|
||||
"429 must expose the configured limit"
|
||||
);
|
||||
|
||||
let body = throttled.text().await?;
|
||||
assert!(body.contains("<Code>TooManyRequests</Code>"), "S3-style error body expected: {body}");
|
||||
|
||||
// Health probes stay exempt even while the client budget is exhausted.
|
||||
let health = client.get(format!("{}/health", env.url)).send().await?;
|
||||
assert_ne!(
|
||||
health.status(),
|
||||
reqwest::StatusCode::TOO_MANY_REQUESTS,
|
||||
"health probes must never be rate limited"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn api_rate_limit_stays_inert_by_default() -> TestResult {
|
||||
init_logging();
|
||||
let mut env = RustFSTestEnvironment::new().await?;
|
||||
env.start_rustfs_server(vec![]).await?;
|
||||
|
||||
let client = local_http_client();
|
||||
let list_buckets_url = format!("{}/", env.url);
|
||||
|
||||
for i in 0..80 {
|
||||
let response = client.get(&list_buckets_url).send().await?;
|
||||
assert_ne!(
|
||||
response.status(),
|
||||
reqwest::StatusCode::TOO_MANY_REQUESTS,
|
||||
"request {i} was throttled although rate limiting is disabled by default"
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -67,6 +67,10 @@ mod bucket_policy_check_test;
|
||||
#[cfg(test)]
|
||||
mod security_boundary_test;
|
||||
|
||||
// Opt-in per-client S3 API rate limiting (backlog#1191)
|
||||
#[cfg(test)]
|
||||
mod api_rate_limit_test;
|
||||
|
||||
// Admin authorization gate: non-admin denial + root-credential lifecycle (backlog#1151 sec-4)
|
||||
#[cfg(test)]
|
||||
mod admin_auth_test;
|
||||
|
||||
@@ -46,7 +46,6 @@ pub mod formpost;
|
||||
pub mod handler;
|
||||
pub mod object;
|
||||
pub mod quota;
|
||||
pub mod ratelimit;
|
||||
pub mod router;
|
||||
pub mod slo;
|
||||
pub mod staticweb;
|
||||
|
||||
@@ -1,434 +0,0 @@
|
||||
// Copyright 2024 RustFS Team
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
|
||||
//! Rate Limiting Support for Swift API
|
||||
//!
|
||||
//! This module implements rate limiting to prevent abuse and ensure fair resource
|
||||
//! allocation across tenants. Rate limits can be applied per-account, per-container,
|
||||
//! or per-IP address.
|
||||
//!
|
||||
//! # Configuration
|
||||
//!
|
||||
//! Rate limits are configured via container metadata:
|
||||
//!
|
||||
//! ```bash
|
||||
//! # Set account-level rate limit: 1000 requests per minute
|
||||
//! swift post -m "X-Account-Meta-Rate-Limit:1000/60"
|
||||
//!
|
||||
//! # Set container-level rate limit: 100 requests per minute
|
||||
//! swift post container -m "X-Container-Meta-Rate-Limit:100/60"
|
||||
//! ```
|
||||
//!
|
||||
//! # Response Headers
|
||||
//!
|
||||
//! Rate limit information is included in all responses:
|
||||
//!
|
||||
//! ```http
|
||||
//! HTTP/1.1 200 OK
|
||||
//! X-RateLimit-Limit: 1000
|
||||
//! X-RateLimit-Remaining: 950
|
||||
//! X-RateLimit-Reset: 1740003600
|
||||
//! ```
|
||||
//!
|
||||
//! When rate limit is exceeded:
|
||||
//!
|
||||
//! ```http
|
||||
//! HTTP/1.1 429 Too Many Requests
|
||||
//! X-RateLimit-Limit: 1000
|
||||
//! X-RateLimit-Remaining: 0
|
||||
//! X-RateLimit-Reset: 1740003600
|
||||
//! Retry-After: 30
|
||||
//! ```
|
||||
//!
|
||||
//! # Algorithm
|
||||
//!
|
||||
//! Uses token bucket algorithm with per-second refill rate:
|
||||
//! - Each request consumes 1 token
|
||||
//! - Tokens refill at configured rate
|
||||
//! - Burst capacity allows temporary spikes
|
||||
|
||||
use super::{SwiftError, SwiftResult};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tracing::debug;
|
||||
|
||||
const LOG_COMPONENT_PROTOCOLS: &str = "protocols";
|
||||
const LOG_SUBSYSTEM_SWIFT_RATELIMIT: &str = "swift_ratelimit";
|
||||
const EVENT_SWIFT_RATELIMIT_STATE: &str = "swift_ratelimit_state";
|
||||
|
||||
/// Rate limit configuration
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RateLimit {
|
||||
/// Maximum requests allowed in time window
|
||||
pub limit: u32,
|
||||
|
||||
/// Time window in seconds
|
||||
pub window_seconds: u32,
|
||||
}
|
||||
|
||||
impl RateLimit {
|
||||
/// Parse rate limit from metadata value
|
||||
///
|
||||
/// Format: "limit/window_seconds" (e.g., "1000/60" = 1000 requests per 60 seconds)
|
||||
pub fn parse(value: &str) -> SwiftResult<Self> {
|
||||
let parts: Vec<&str> = value.split('/').collect();
|
||||
if parts.len() != 2 {
|
||||
return Err(SwiftError::BadRequest(format!(
|
||||
"Invalid rate limit format: {}. Expected format: limit/window_seconds",
|
||||
value
|
||||
)));
|
||||
}
|
||||
|
||||
let limit = parts[0]
|
||||
.parse::<u32>()
|
||||
.map_err(|_| SwiftError::BadRequest(format!("Invalid rate limit value: {}", parts[0])))?;
|
||||
|
||||
let window_seconds = parts[1]
|
||||
.parse::<u32>()
|
||||
.map_err(|_| SwiftError::BadRequest(format!("Invalid window value: {}", parts[1])))?;
|
||||
|
||||
if window_seconds == 0 {
|
||||
return Err(SwiftError::BadRequest("Rate limit window cannot be zero".to_string()));
|
||||
}
|
||||
|
||||
Ok(RateLimit { limit, window_seconds })
|
||||
}
|
||||
|
||||
/// Calculate refill rate (tokens per second)
|
||||
pub fn refill_rate(&self) -> f64 {
|
||||
self.limit as f64 / self.window_seconds as f64
|
||||
}
|
||||
}
|
||||
|
||||
/// Token bucket for rate limiting
|
||||
#[derive(Debug, Clone)]
|
||||
struct TokenBucket {
|
||||
/// Maximum tokens (burst capacity)
|
||||
capacity: u32,
|
||||
|
||||
/// Current available tokens
|
||||
tokens: f64,
|
||||
|
||||
/// Refill rate (tokens per second)
|
||||
refill_rate: f64,
|
||||
|
||||
/// Last refill timestamp (Unix seconds)
|
||||
last_refill: u64,
|
||||
}
|
||||
|
||||
impl TokenBucket {
|
||||
fn new(rate_limit: &RateLimit) -> Self {
|
||||
let capacity = rate_limit.limit;
|
||||
let refill_rate = rate_limit.refill_rate();
|
||||
|
||||
TokenBucket {
|
||||
capacity,
|
||||
tokens: capacity as f64, // Start full
|
||||
refill_rate,
|
||||
last_refill: current_timestamp(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Try to consume a token
|
||||
///
|
||||
/// Returns Ok(remaining_tokens) if successful, Err(retry_after_seconds) if rate limited
|
||||
fn try_consume(&mut self) -> Result<u32, u64> {
|
||||
// Refill tokens based on time elapsed
|
||||
let now = current_timestamp();
|
||||
let elapsed = now.saturating_sub(self.last_refill);
|
||||
|
||||
if elapsed > 0 {
|
||||
let refill_amount = self.refill_rate * elapsed as f64;
|
||||
self.tokens = (self.tokens + refill_amount).min(self.capacity as f64);
|
||||
self.last_refill = now;
|
||||
}
|
||||
|
||||
// Try to consume 1 token
|
||||
if self.tokens >= 1.0 {
|
||||
self.tokens -= 1.0;
|
||||
Ok(self.tokens.floor() as u32)
|
||||
} else {
|
||||
// Calculate retry-after: time until 1 token is available
|
||||
let tokens_needed = 1.0 - self.tokens;
|
||||
let retry_after = (tokens_needed / self.refill_rate).ceil() as u64;
|
||||
Err(retry_after)
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current token count
|
||||
fn remaining(&mut self) -> u32 {
|
||||
// Refill tokens based on time elapsed
|
||||
let now = current_timestamp();
|
||||
let elapsed = now.saturating_sub(self.last_refill);
|
||||
|
||||
if elapsed > 0 {
|
||||
let refill_amount = self.refill_rate * elapsed as f64;
|
||||
self.tokens = (self.tokens + refill_amount).min(self.capacity as f64);
|
||||
self.last_refill = now;
|
||||
}
|
||||
|
||||
self.tokens.floor() as u32
|
||||
}
|
||||
|
||||
/// Get reset timestamp (when bucket will be full)
|
||||
fn reset_timestamp(&self, now: u64) -> u64 {
|
||||
if self.tokens >= self.capacity as f64 {
|
||||
now
|
||||
} else {
|
||||
let tokens_to_refill = self.capacity as f64 - self.tokens;
|
||||
let seconds_to_full = (tokens_to_refill / self.refill_rate).ceil() as u64;
|
||||
now + seconds_to_full
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Global rate limiter state (in-memory)
|
||||
///
|
||||
/// In production, this should be backed by Redis or similar distributed store
|
||||
#[derive(Clone)]
|
||||
pub struct RateLimiter {
|
||||
buckets: Arc<Mutex<HashMap<String, TokenBucket>>>,
|
||||
}
|
||||
|
||||
impl RateLimiter {
|
||||
/// Create new rate limiter
|
||||
pub fn new() -> Self {
|
||||
RateLimiter {
|
||||
buckets: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check and consume rate limit quota
|
||||
///
|
||||
/// Returns (remaining, reset_timestamp) if successful,
|
||||
/// or SwiftError::TooManyRequests if rate limited
|
||||
pub fn check_rate_limit(&self, key: &str, rate_limit: &RateLimit) -> SwiftResult<(u32, u64)> {
|
||||
let mut buckets = self.buckets.lock().expect("operation should succeed");
|
||||
|
||||
// Get or create bucket for this key
|
||||
let bucket = buckets.entry(key.to_string()).or_insert_with(|| TokenBucket::new(rate_limit));
|
||||
|
||||
let now = current_timestamp();
|
||||
let reset = bucket.reset_timestamp(now);
|
||||
|
||||
match bucket.try_consume() {
|
||||
Ok(remaining) => {
|
||||
debug!(event = EVENT_SWIFT_RATELIMIT_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SWIFT_RATELIMIT, key = %key, remaining, result = "allowed", "swift ratelimit state changed");
|
||||
Ok((remaining, reset))
|
||||
}
|
||||
Err(retry_after) => {
|
||||
debug!(event = EVENT_SWIFT_RATELIMIT_STATE, component = LOG_COMPONENT_PROTOCOLS, subsystem = LOG_SUBSYSTEM_SWIFT_RATELIMIT, key = %key, retry_after, result = "limited", "swift ratelimit state changed");
|
||||
Err(SwiftError::TooManyRequests {
|
||||
retry_after,
|
||||
limit: rate_limit.limit,
|
||||
reset,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current rate limit status without consuming quota
|
||||
pub fn get_status(&self, key: &str, rate_limit: &RateLimit) -> (u32, u64) {
|
||||
let mut buckets = self.buckets.lock().expect("operation should succeed");
|
||||
|
||||
let bucket = buckets.entry(key.to_string()).or_insert_with(|| TokenBucket::new(rate_limit));
|
||||
|
||||
let now = current_timestamp();
|
||||
let remaining = bucket.remaining();
|
||||
let reset = bucket.reset_timestamp(now);
|
||||
|
||||
(remaining, reset)
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RateLimiter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current Unix timestamp in seconds
|
||||
fn current_timestamp() -> u64 {
|
||||
SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs()
|
||||
}
|
||||
|
||||
/// Extract rate limit from account or container metadata
|
||||
pub fn extract_rate_limit(metadata: &HashMap<String, String>) -> Option<RateLimit> {
|
||||
// Check for rate limit in metadata
|
||||
if let Some(rate_limit_str) = metadata
|
||||
.get("x-account-meta-rate-limit")
|
||||
.or_else(|| metadata.get("x-container-meta-rate-limit"))
|
||||
{
|
||||
RateLimit::parse(rate_limit_str).ok()
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Build rate limit key for tracking
|
||||
pub fn build_rate_limit_key(account: &str, container: Option<&str>) -> String {
|
||||
if let Some(cont) = container {
|
||||
format!("account:{}:container:{}", account, cont)
|
||||
} else {
|
||||
format!("account:{}", account)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_rate_limit_valid() {
|
||||
let rate_limit = RateLimit::parse("1000/60").expect("operation should succeed");
|
||||
assert_eq!(rate_limit.limit, 1000);
|
||||
assert_eq!(rate_limit.window_seconds, 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_rate_limit_invalid_format() {
|
||||
let result = RateLimit::parse("1000");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_rate_limit_invalid_limit() {
|
||||
let result = RateLimit::parse("not_a_number/60");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_rate_limit_invalid_window() {
|
||||
let result = RateLimit::parse("1000/not_a_number");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_rate_limit_zero_window() {
|
||||
let result = RateLimit::parse("1000/0");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_refill_rate() {
|
||||
let rate_limit = RateLimit {
|
||||
limit: 1000,
|
||||
window_seconds: 60,
|
||||
};
|
||||
assert!((rate_limit.refill_rate() - 16.666666).abs() < 0.001);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_bucket_consume() {
|
||||
let rate_limit = RateLimit {
|
||||
limit: 10,
|
||||
window_seconds: 60,
|
||||
};
|
||||
let mut bucket = TokenBucket::new(&rate_limit);
|
||||
|
||||
// Should be able to consume up to limit
|
||||
for i in 0..10 {
|
||||
let result = bucket.try_consume();
|
||||
assert!(result.is_ok(), "Token {} should succeed", i);
|
||||
}
|
||||
|
||||
// 11th request should fail
|
||||
let result = bucket.try_consume();
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_token_bucket_remaining() {
|
||||
let rate_limit = RateLimit {
|
||||
limit: 100,
|
||||
window_seconds: 60,
|
||||
};
|
||||
let mut bucket = TokenBucket::new(&rate_limit);
|
||||
|
||||
// Initial: 100 tokens
|
||||
assert_eq!(bucket.remaining(), 100);
|
||||
|
||||
// Consume 10
|
||||
for _ in 0..10 {
|
||||
bucket.try_consume().expect("operation should succeed");
|
||||
}
|
||||
|
||||
assert_eq!(bucket.remaining(), 90);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limiter() {
|
||||
let limiter = RateLimiter::new();
|
||||
let rate_limit = RateLimit {
|
||||
limit: 5,
|
||||
window_seconds: 60,
|
||||
};
|
||||
|
||||
// Should allow 5 requests
|
||||
for _ in 0..5 {
|
||||
let result = limiter.check_rate_limit("test_key", &rate_limit);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
// 6th request should fail
|
||||
let result = limiter.check_rate_limit("test_key", &rate_limit);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_rate_limit_account() {
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert("x-account-meta-rate-limit".to_string(), "1000/60".to_string());
|
||||
|
||||
let rate_limit = extract_rate_limit(&metadata);
|
||||
assert!(rate_limit.is_some());
|
||||
|
||||
let rate_limit = rate_limit.expect("operation should succeed");
|
||||
assert_eq!(rate_limit.limit, 1000);
|
||||
assert_eq!(rate_limit.window_seconds, 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_rate_limit_container() {
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert("x-container-meta-rate-limit".to_string(), "100/60".to_string());
|
||||
|
||||
let rate_limit = extract_rate_limit(&metadata);
|
||||
assert!(rate_limit.is_some());
|
||||
|
||||
let rate_limit = rate_limit.expect("operation should succeed");
|
||||
assert_eq!(rate_limit.limit, 100);
|
||||
assert_eq!(rate_limit.window_seconds, 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_rate_limit_none() {
|
||||
let metadata = HashMap::new();
|
||||
let rate_limit = extract_rate_limit(&metadata);
|
||||
assert!(rate_limit.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_rate_limit_key_account() {
|
||||
let key = build_rate_limit_key("AUTH_test", None);
|
||||
assert_eq!(key, "account:AUTH_test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_rate_limit_key_container() {
|
||||
let key = build_rate_limit_key("AUTH_test", Some("my-container"));
|
||||
assert_eq!(key, "account:AUTH_test:container:my-container");
|
||||
}
|
||||
}
|
||||
@@ -43,36 +43,4 @@ mod swift_integration {
|
||||
let parsed = expiration::parse_delete_at(delete_at).unwrap();
|
||||
assert_eq!(parsed, 1740000000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_multiple_rate_limit_keys() {
|
||||
let limiter = ratelimit::RateLimiter::new();
|
||||
let rate = ratelimit::RateLimit {
|
||||
limit: 3,
|
||||
window_seconds: 60,
|
||||
};
|
||||
|
||||
// Different keys should have separate limits
|
||||
for _ in 0..3 {
|
||||
assert!(limiter.check_rate_limit("key1", &rate).is_ok());
|
||||
assert!(limiter.check_rate_limit("key2", &rate).is_ok());
|
||||
}
|
||||
|
||||
// Both keys should now be exhausted
|
||||
assert!(limiter.check_rate_limit("key1", &rate).is_err());
|
||||
assert!(limiter.check_rate_limit("key2", &rate).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rate_limit_metadata_extraction() {
|
||||
let mut metadata = HashMap::new();
|
||||
metadata.insert("x-account-meta-rate-limit".to_string(), "1000/60".to_string());
|
||||
|
||||
let rate_limit = ratelimit::extract_rate_limit(&metadata);
|
||||
assert!(rate_limit.is_some());
|
||||
|
||||
let rate_limit = rate_limit.unwrap();
|
||||
assert_eq!(rate_limit.limit, 1000);
|
||||
assert_eq!(rate_limit.window_seconds, 60);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
#![cfg(feature = "swift")]
|
||||
|
||||
use rustfs_protocols::swift::{quota, ratelimit, slo, symlink, sync, tempurl, versioning};
|
||||
use rustfs_protocols::swift::{quota, slo, symlink, sync, tempurl, versioning};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Test sync configuration parsing
|
||||
@@ -92,14 +92,6 @@ fn test_symlink_detection() {
|
||||
let _is_symlink = symlink::is_symlink(&metadata);
|
||||
}
|
||||
|
||||
/// Test rate limit parsing
|
||||
#[test]
|
||||
fn test_rate_limit_parsing() {
|
||||
let rl = ratelimit::RateLimit::parse("100/60").unwrap();
|
||||
assert_eq!(rl.limit, 100);
|
||||
assert_eq!(rl.window_seconds, 60);
|
||||
}
|
||||
|
||||
/// Test quota structure
|
||||
#[test]
|
||||
fn test_quota_structure() {
|
||||
|
||||
Reference in New Issue
Block a user