Copied from api_gateway, removed REST handlers (port 8080), added tonic-web + CORS for grpc-web browser access. Binary renamed: api-gateway → api Changes: - Package name: api-gateway → api - Deleted src/handlers/ (REST ML endpoints on port 8080) - Added tonic-web 0.13 + tower-http CORS layer - Server::builder().accept_http1(true) for grpc-web - CORS_ORIGINS env var (default http://localhost:5173) - Metrics server on port 9091 (axum) preserved - All 95 lib tests pass, 0 clippy warnings - Added services/api to workspace members Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
655 lines
17 KiB
Rust
655 lines
17 KiB
Rust
//! Advanced Rate Limiter Tests - Wave 17 Agent 17.10
|
|
//!
|
|
//! Comprehensive tests for token bucket algorithm, cache management,
|
|
//! endpoint configuration, and error handling with Redis backend.
|
|
//!
|
|
//! Coverage targets:
|
|
//! - Token bucket refill mechanics
|
|
//! - LRU cache eviction
|
|
//! - Endpoint configuration updates
|
|
//! - Redis connection handling
|
|
//! - Concurrent access patterns
|
|
|
|
use anyhow::Result;
|
|
use std::time::Duration;
|
|
use uuid::Uuid;
|
|
|
|
use api::routing::{RateLimitConfig, RateLimiter};
|
|
|
|
const REDIS_URL: &str = "redis://localhost:6379";
|
|
|
|
// ============================================================================
|
|
// Token Bucket Mechanics Tests (5 tests)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_token_bucket_capacity_enforcement() -> Result<()> {
|
|
println!("\n=== Test: Token Bucket Capacity Enforcement ===");
|
|
|
|
let rate_limiter = RateLimiter::new(REDIS_URL).await?;
|
|
let user_id = Uuid::new_v4();
|
|
|
|
// config.update has 10 req/s capacity
|
|
let mut allowed = 0;
|
|
for _ in 0..20 {
|
|
if rate_limiter.check_limit(&user_id, "config.update").await? {
|
|
allowed += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
println!(" Allowed: {}/20", allowed);
|
|
assert!(
|
|
allowed >= 10 && allowed <= 12,
|
|
"Should allow ~10 requests (capacity)"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_token_bucket_refill_rate() -> Result<()> {
|
|
println!("\n=== Test: Token Bucket Refill Rate ===");
|
|
|
|
let rate_limiter = RateLimiter::new(REDIS_URL).await?;
|
|
let user_id = Uuid::new_v4();
|
|
|
|
// Exhaust bucket
|
|
for _ in 0..15 {
|
|
let _ = rate_limiter.check_limit(&user_id, "config.update").await?;
|
|
}
|
|
|
|
// Wait for partial refill (0.5s = ~5 tokens at 10 req/s)
|
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
|
|
|
let mut refilled = 0;
|
|
for _ in 0..10 {
|
|
if rate_limiter.check_limit(&user_id, "config.update").await? {
|
|
refilled += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
println!(" Refilled: {} tokens in 500ms", refilled);
|
|
assert!(refilled >= 4 && refilled <= 6, "Should refill ~5 tokens");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_token_bucket_burst_handling() -> Result<()> {
|
|
println!("\n=== Test: Token Bucket Burst Handling ===");
|
|
|
|
let rate_limiter = RateLimiter::new(REDIS_URL).await?;
|
|
let user_id = Uuid::new_v4();
|
|
|
|
// trading.submit_order has 100 req/s capacity
|
|
let mut burst_allowed = 0;
|
|
|
|
// Make burst of 150 requests
|
|
for _ in 0..150 {
|
|
if rate_limiter
|
|
.check_limit(&user_id, "trading.submit_order")
|
|
.await?
|
|
{
|
|
burst_allowed += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
println!(" Burst allowed: {}/150", burst_allowed);
|
|
assert!(
|
|
burst_allowed >= 95 && burst_allowed <= 105,
|
|
"Should handle burst up to capacity"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_token_bucket_multiple_endpoints() -> Result<()> {
|
|
println!("\n=== Test: Multiple Endpoints Independent Limits ===");
|
|
|
|
let rate_limiter = RateLimiter::new(REDIS_URL).await?;
|
|
let user_id = Uuid::new_v4();
|
|
|
|
// Exhaust one endpoint
|
|
for _ in 0..20 {
|
|
let _ = rate_limiter.check_limit(&user_id, "config.update").await?;
|
|
}
|
|
|
|
// Other endpoint should have full capacity
|
|
let mut trading_allowed = 0;
|
|
for _ in 0..50 {
|
|
if rate_limiter
|
|
.check_limit(&user_id, "trading.submit_order")
|
|
.await?
|
|
{
|
|
trading_allowed += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
println!(" Trading endpoint allowed: {}", trading_allowed);
|
|
assert!(trading_allowed >= 45, "Should have independent limit");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_token_bucket_slow_refill() -> Result<()> {
|
|
println!("\n=== Test: Slow Refill Rate (Backtesting) ===");
|
|
|
|
let rate_limiter = RateLimiter::new(REDIS_URL).await?;
|
|
let user_id = Uuid::new_v4();
|
|
|
|
// backtesting.run has 5 req/min (very slow refill)
|
|
let mut initial = 0;
|
|
for _ in 0..10 {
|
|
if rate_limiter
|
|
.check_limit(&user_id, "backtesting.run")
|
|
.await?
|
|
{
|
|
initial += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
println!(" Initial allowed: {}", initial);
|
|
assert!(initial <= 6, "Should be rate limited");
|
|
|
|
// Wait 12 seconds (should refill ~1 token)
|
|
tokio::time::sleep(Duration::from_secs(12)).await;
|
|
|
|
let mut refilled = 0;
|
|
for _ in 0..5 {
|
|
if rate_limiter
|
|
.check_limit(&user_id, "backtesting.run")
|
|
.await?
|
|
{
|
|
refilled += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
println!(" After 12s: {} new tokens", refilled);
|
|
assert!(refilled >= 0 && refilled <= 2, "Should refill slowly");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Cache Management Tests (5 tests)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_cache_hit_after_first_check() -> Result<()> {
|
|
println!("\n=== Test: Cache Hit After First Check ===");
|
|
|
|
let rate_limiter = RateLimiter::new(REDIS_URL).await?;
|
|
let user_id = Uuid::new_v4();
|
|
|
|
// First check (cache miss, hits Redis)
|
|
let start = std::time::Instant::now();
|
|
let _ = rate_limiter
|
|
.check_limit(&user_id, "trading.submit_order")
|
|
.await?;
|
|
let first_duration = start.elapsed();
|
|
|
|
// Second check (cache hit, <8ns expected)
|
|
let start = std::time::Instant::now();
|
|
let _ = rate_limiter
|
|
.check_limit(&user_id, "trading.submit_order")
|
|
.await?;
|
|
let second_duration = start.elapsed();
|
|
|
|
println!(" First check: {:?}", first_duration);
|
|
println!(" Second check: {:?}", second_duration);
|
|
|
|
// Second check should be much faster
|
|
assert!(second_duration < first_duration);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cache_expiration() -> Result<()> {
|
|
println!("\n=== Test: Cache TTL Expiration ===");
|
|
|
|
let rate_limiter = RateLimiter::new(REDIS_URL).await?;
|
|
let user_id = Uuid::new_v4();
|
|
|
|
// Make initial check
|
|
let _ = rate_limiter
|
|
.check_limit(&user_id, "trading.submit_order")
|
|
.await?;
|
|
|
|
// Wait for cache TTL to expire (1 second)
|
|
tokio::time::sleep(Duration::from_millis(1100)).await;
|
|
|
|
// Next check should be cache miss (go to Redis)
|
|
let start = std::time::Instant::now();
|
|
let _ = rate_limiter
|
|
.check_limit(&user_id, "trading.submit_order")
|
|
.await?;
|
|
let duration = start.elapsed();
|
|
|
|
println!(" After TTL expiry: {:?}", duration);
|
|
// Should hit Redis again
|
|
assert!(duration > Duration::from_micros(100));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cache_size_limit_and_eviction() -> Result<()> {
|
|
println!("\n=== Test: Cache LRU Eviction ===");
|
|
|
|
let rate_limiter = RateLimiter::new(REDIS_URL).await?;
|
|
|
|
// Fill cache with many different users (10,000+ entries)
|
|
println!(" Creating 10,500 cache entries...");
|
|
for i in 0..10_500 {
|
|
let user_id = Uuid::new_v4();
|
|
let _ = rate_limiter
|
|
.check_limit(&user_id, "trading.submit_order")
|
|
.await?;
|
|
|
|
if i % 1000 == 0 {
|
|
println!(" Created {} entries", i);
|
|
}
|
|
}
|
|
|
|
let stats = rate_limiter.get_cache_stats().await;
|
|
|
|
println!(" Cache size: {}/{}", stats.size, stats.max_size);
|
|
println!(" LRU eviction: {} entries removed", 10_500 - stats.size);
|
|
|
|
// Cache should not exceed max size
|
|
assert!(
|
|
stats.size <= stats.max_size,
|
|
"Cache should not exceed max size"
|
|
);
|
|
assert!(stats.size >= 9_000, "Cache should keep most recent entries");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cache_clear_operation() -> Result<()> {
|
|
println!("\n=== Test: Cache Clear Operation ===");
|
|
|
|
let rate_limiter = RateLimiter::new(REDIS_URL).await?;
|
|
|
|
// Populate cache
|
|
for _ in 0..100 {
|
|
let user_id = Uuid::new_v4();
|
|
let _ = rate_limiter
|
|
.check_limit(&user_id, "trading.submit_order")
|
|
.await?;
|
|
}
|
|
|
|
let stats_before = rate_limiter.get_cache_stats().await;
|
|
println!(" Cache before clear: {} entries", stats_before.size);
|
|
|
|
// Clear cache
|
|
rate_limiter.clear_cache().await;
|
|
|
|
let stats_after = rate_limiter.get_cache_stats().await;
|
|
println!(" Cache after clear: {} entries", stats_after.size);
|
|
|
|
assert_eq!(stats_after.size, 0, "Cache should be empty after clear");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_cache_concurrent_access() -> Result<()> {
|
|
println!("\n=== Test: Cache Concurrent Access ===");
|
|
|
|
let rate_limiter = RateLimiter::new(REDIS_URL).await?;
|
|
let user_id = Uuid::new_v4();
|
|
|
|
// Spawn 100 concurrent tasks accessing same cache entry
|
|
let mut handles = vec![];
|
|
|
|
for _ in 0..100 {
|
|
let limiter = rate_limiter.clone();
|
|
let uid = user_id;
|
|
|
|
let handle =
|
|
tokio::spawn(async move { limiter.check_limit(&uid, "trading.submit_order").await });
|
|
|
|
handles.push(handle);
|
|
}
|
|
|
|
// Collect results
|
|
let mut success_count = 0;
|
|
let mut error_count = 0;
|
|
|
|
for handle in handles {
|
|
match handle.await {
|
|
Ok(Ok(_)) => success_count += 1,
|
|
Ok(Err(_)) => error_count += 1,
|
|
Err(_) => error_count += 1,
|
|
}
|
|
}
|
|
|
|
println!(" Success: {}, Errors: {}", success_count, error_count);
|
|
assert_eq!(
|
|
success_count + error_count,
|
|
100,
|
|
"All tasks should complete"
|
|
);
|
|
assert_eq!(error_count, 0, "No errors should occur");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Endpoint Configuration Tests (5 tests)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_default_endpoint_config() -> Result<()> {
|
|
println!("\n=== Test: Default Endpoint Configuration ===");
|
|
|
|
let rate_limiter = RateLimiter::new(REDIS_URL).await?;
|
|
let user_id = Uuid::new_v4();
|
|
|
|
// Unknown endpoint should use default config (50 req/s)
|
|
let mut allowed = 0;
|
|
for _ in 0..75 {
|
|
if rate_limiter
|
|
.check_limit(&user_id, "unknown.endpoint")
|
|
.await?
|
|
{
|
|
allowed += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
println!(" Default endpoint allowed: {}/75", allowed);
|
|
assert!(
|
|
allowed >= 45 && allowed <= 55,
|
|
"Should use default limit (50 req/s)"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_update_endpoint_config() -> Result<()> {
|
|
println!("\n=== Test: Dynamic Endpoint Configuration ===");
|
|
|
|
let rate_limiter = RateLimiter::new(REDIS_URL).await?;
|
|
|
|
// Create custom config
|
|
let custom_config = RateLimitConfig {
|
|
endpoint: "custom.endpoint".to_string(),
|
|
capacity: 20.0,
|
|
refill_rate: 20.0,
|
|
burst_size: 5,
|
|
};
|
|
|
|
// Update configuration
|
|
rate_limiter.set_endpoint_config(custom_config).await;
|
|
|
|
let user_id = Uuid::new_v4();
|
|
|
|
// Test custom limit
|
|
let mut allowed = 0;
|
|
for _ in 0..30 {
|
|
if rate_limiter
|
|
.check_limit(&user_id, "custom.endpoint")
|
|
.await?
|
|
{
|
|
allowed += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
println!(" Custom endpoint allowed: {}/30", allowed);
|
|
assert!(
|
|
allowed >= 18 && allowed <= 22,
|
|
"Should use custom limit (20 req/s)"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_trading_endpoint_high_capacity() -> Result<()> {
|
|
println!("\n=== Test: Trading Endpoint High Capacity ===");
|
|
|
|
let config = RateLimitConfig::trading_submit_order();
|
|
|
|
assert_eq!(config.capacity, 100.0);
|
|
assert_eq!(config.refill_rate, 100.0);
|
|
assert_eq!(config.burst_size, 10);
|
|
println!(
|
|
" ✓ Trading config: {} req/s, burst {}",
|
|
config.refill_rate, config.burst_size
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_config_endpoint_low_capacity() -> Result<()> {
|
|
println!("\n=== Test: Config Update Endpoint Low Capacity ===");
|
|
|
|
let config = RateLimitConfig::config_update();
|
|
|
|
assert_eq!(config.capacity, 10.0);
|
|
assert_eq!(config.refill_rate, 10.0);
|
|
assert_eq!(config.burst_size, 2);
|
|
println!(
|
|
" ✓ Config update: {} req/s, burst {}",
|
|
config.refill_rate, config.burst_size
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_backtesting_endpoint_very_low_rate() -> Result<()> {
|
|
println!("\n=== Test: Backtesting Endpoint Very Low Rate ===");
|
|
|
|
let config = RateLimitConfig::backtesting_run();
|
|
|
|
assert_eq!(config.capacity, 5.0);
|
|
assert!(config.refill_rate < 0.1); // 5 requests per minute
|
|
assert_eq!(config.burst_size, 1);
|
|
println!(
|
|
" ✓ Backtesting: {:.4} req/s (5 req/min), burst {}",
|
|
config.refill_rate, config.burst_size
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
// ============================================================================
|
|
// Redis Integration Tests (5 tests)
|
|
// ============================================================================
|
|
|
|
#[tokio::test]
|
|
async fn test_redis_state_shared_across_instances() -> Result<()> {
|
|
println!("\n=== Test: Redis State Sharing ===");
|
|
|
|
let limiter1 = RateLimiter::new(REDIS_URL).await?;
|
|
let limiter2 = RateLimiter::new(REDIS_URL).await?;
|
|
|
|
let user_id = Uuid::new_v4();
|
|
|
|
// Use first instance to exhaust tokens
|
|
let mut count1 = 0;
|
|
for _ in 0..10 {
|
|
if limiter1.check_limit(&user_id, "config.update").await? {
|
|
count1 += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
println!(" Instance 1 allowed: {}", count1);
|
|
|
|
// Second instance should see same state
|
|
let mut count2 = 0;
|
|
for _ in 0..10 {
|
|
if limiter2.check_limit(&user_id, "config.update").await? {
|
|
count2 += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
println!(" Instance 2 allowed: {}", count2);
|
|
|
|
// Total should not exceed capacity
|
|
assert!(
|
|
count1 + count2 <= 12,
|
|
"Total should respect shared Redis state"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_redis_lua_script_atomicity() -> Result<()> {
|
|
println!("\n=== Test: Redis Lua Script Atomicity ===");
|
|
|
|
let rate_limiter = RateLimiter::new(REDIS_URL).await?;
|
|
let user_id = Uuid::new_v4();
|
|
|
|
// Launch 200 concurrent requests
|
|
let mut handles = vec![];
|
|
|
|
for _ in 0..200 {
|
|
let limiter = rate_limiter.clone();
|
|
let uid = user_id;
|
|
|
|
let handle = tokio::spawn(async move { limiter.check_limit(&uid, "config.update").await });
|
|
|
|
handles.push(handle);
|
|
}
|
|
|
|
// Collect results
|
|
let mut allowed = 0;
|
|
let mut denied = 0;
|
|
|
|
for handle in handles {
|
|
match handle.await? {
|
|
Ok(true) => allowed += 1,
|
|
Ok(false) => denied += 1,
|
|
Err(_) => {},
|
|
}
|
|
}
|
|
|
|
println!(" Allowed: {}, Denied: {}", allowed, denied);
|
|
|
|
// Lua script should ensure exact limit (10 req/s for config.update)
|
|
assert_eq!(allowed + denied, 200, "All requests should complete");
|
|
assert!(
|
|
allowed >= 9 && allowed <= 12,
|
|
"Should allow ~10 requests atomically"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_redis_key_ttl_set() -> Result<()> {
|
|
println!("\n=== Test: Redis Key TTL (300s) ===");
|
|
|
|
let rate_limiter = RateLimiter::new(REDIS_URL).await?;
|
|
let user_id = Uuid::new_v4();
|
|
|
|
// Make request to create Redis key
|
|
let _ = rate_limiter
|
|
.check_limit(&user_id, "trading.submit_order")
|
|
.await?;
|
|
|
|
println!(" ✓ Redis key created with 300s TTL");
|
|
println!(" (Manual verification: redis-cli TTL ratelimit:...)");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_redis_multiple_users_isolated() -> Result<()> {
|
|
println!("\n=== Test: Per-User Rate Limit Isolation ===");
|
|
|
|
let rate_limiter = RateLimiter::new(REDIS_URL).await?;
|
|
|
|
// Create 10 users
|
|
let users: Vec<Uuid> = (0..10).map(|_| Uuid::new_v4()).collect();
|
|
|
|
// Each user should have independent rate limit
|
|
for (i, user_id) in users.iter().enumerate() {
|
|
let mut allowed = 0;
|
|
for _ in 0..15 {
|
|
if rate_limiter.check_limit(user_id, "config.update").await? {
|
|
allowed += 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
println!(" User {} allowed: {}", i, allowed);
|
|
assert!(
|
|
allowed >= 9 && allowed <= 12,
|
|
"Each user should have independent limit"
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_redis_connection_reuse() -> Result<()> {
|
|
println!("\n=== Test: Redis Connection Pool Reuse ===");
|
|
|
|
let rate_limiter = RateLimiter::new(REDIS_URL).await?;
|
|
|
|
// Make 1000 requests to test connection pooling
|
|
let mut handles = vec![];
|
|
|
|
for _ in 0..1000 {
|
|
let limiter = rate_limiter.clone();
|
|
let user_id = Uuid::new_v4();
|
|
|
|
let handle =
|
|
tokio::spawn(
|
|
async move { limiter.check_limit(&user_id, "trading.submit_order").await },
|
|
);
|
|
|
|
handles.push(handle);
|
|
}
|
|
|
|
// All should complete without errors
|
|
let mut success = 0;
|
|
let mut errors = 0;
|
|
|
|
for handle in handles {
|
|
match handle.await {
|
|
Ok(Ok(_)) => success += 1,
|
|
_ => errors += 1,
|
|
}
|
|
}
|
|
|
|
println!(" Success: {}, Errors: {}", success, errors);
|
|
assert_eq!(success + errors, 1000);
|
|
assert_eq!(errors, 0, "Connection pool should handle all requests");
|
|
|
|
Ok(())
|
|
}
|