Files
foxhunt/services/api/benches/rate_limiting_perf.rs
jgrusewski e50ea55064 feat: create services/api/ — unified gRPC gateway with tonic-web
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>
2026-03-04 23:32:46 +01:00

321 lines
8.7 KiB
Rust

//! Rate Limiting Performance Benchmark
//!
//! Measures rate limiter performance:
//! - TARGET: <50ns per check
//! - Atomic counter operations
//! - Token bucket algorithm
//! - Sliding window counters
//! - Concurrent access patterns
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
/// Atomic counter-based rate limiter (fastest)
struct AtomicRateLimiter {
counters: HashMap<String, Arc<AtomicU64>>,
limit: u64,
}
impl AtomicRateLimiter {
fn new(limit: u64) -> Self {
Self {
counters: HashMap::new(),
limit,
}
}
fn check(&self, user_id: &str) -> bool {
if let Some(counter) = self.counters.get(user_id) {
let count = counter.fetch_add(1, Ordering::Relaxed);
count < self.limit
} else {
true // First request always allowed
}
}
fn with_user(mut self, user_id: &str) -> Self {
self.counters
.insert(user_id.to_string(), Arc::new(AtomicU64::new(0)));
self
}
}
/// Token bucket rate limiter
struct TokenBucket {
tokens: Mutex<f64>,
last_refill: Mutex<Instant>,
capacity: f64,
refill_rate: f64, // tokens per second
}
impl TokenBucket {
fn new(capacity: f64, refill_rate: f64) -> Self {
Self {
tokens: Mutex::new(capacity),
last_refill: Mutex::new(Instant::now()),
capacity,
refill_rate,
}
}
fn check(&self) -> bool {
let mut tokens = self.tokens.lock().expect("INVARIANT: Lock should not be poisoned");
let mut last_refill = self.last_refill.lock().expect("INVARIANT: Lock should not be poisoned");
let now = Instant::now();
let elapsed = now.duration_since(*last_refill).as_secs_f64();
// Refill tokens
*tokens = (*tokens + (elapsed * self.refill_rate)).min(self.capacity);
*last_refill = now;
if *tokens >= 1.0 {
*tokens -= 1.0;
true
} else {
false
}
}
}
/// Sliding window counter
struct SlidingWindow {
window: Mutex<Vec<Instant>>,
window_size: Duration,
limit: usize,
}
impl SlidingWindow {
fn new(window_size: Duration, limit: usize) -> Self {
Self {
window: Mutex::new(Vec::new()),
window_size,
limit,
}
}
fn check(&self) -> bool {
let mut window = self.window.lock().expect("INVARIANT: Lock should not be poisoned");
let now = Instant::now();
// Remove expired entries
window.retain(|&time| now.duration_since(time) < self.window_size);
if window.len() < self.limit {
window.push(now);
true
} else {
false
}
}
}
/// Benchmark 1: Atomic counter (TARGET: <50ns)
fn bench_atomic_rate_limiter(c: &mut Criterion) {
let limiter = AtomicRateLimiter::new(1_000_000).with_user("user123");
c.bench_function("atomic_rate_limiter", |b| {
b.iter(|| {
let allowed = limiter.check(black_box("user123"));
black_box(allowed);
});
});
}
/// Benchmark 2: Token bucket algorithm
fn bench_token_bucket(c: &mut Criterion) {
let bucket = TokenBucket::new(100.0, 100.0);
c.bench_function("token_bucket_rate_limiter", |b| {
b.iter(|| {
let allowed = bucket.check();
black_box(allowed);
});
});
}
/// Benchmark 3: Sliding window counter
fn bench_sliding_window(c: &mut Criterion) {
let window = SlidingWindow::new(Duration::from_secs(1), 100);
c.bench_function("sliding_window_rate_limiter", |b| {
b.iter(|| {
let allowed = window.check();
black_box(allowed);
});
});
}
/// Benchmark 4: Different user counts
fn bench_user_scaling(c: &mut Criterion) {
let mut group = c.benchmark_group("rate_limiter_user_scaling");
for num_users in &[10, 100, 1_000, 10_000] {
let mut limiter = AtomicRateLimiter::new(1_000_000);
for i in 0..*num_users {
limiter = limiter.with_user(&format!("user{}", i));
}
group.bench_with_input(
BenchmarkId::new("atomic_limiter", num_users),
num_users,
|b, &n| {
b.iter(|| {
let user_id = format!("user{}", black_box(n / 2));
let allowed = limiter.check(&user_id);
black_box(allowed);
});
},
);
}
group.finish();
}
/// Benchmark 5: Burst handling
fn bench_burst_handling(c: &mut Criterion) {
let bucket = TokenBucket::new(100.0, 100.0);
c.bench_function("burst_100_requests", |b| {
b.iter(|| {
let mut allowed_count = 0;
for _ in 0..100 {
if bucket.check() {
allowed_count += 1;
}
}
black_box(allowed_count);
});
});
}
/// Benchmark 6: Rate limiter with refill overhead
fn bench_refill_overhead(c: &mut Criterion) {
let bucket = TokenBucket::new(1000.0, 1000.0);
let mut group = c.benchmark_group("refill_overhead");
// Simulate different request patterns
group.bench_function("high_frequency_no_wait", |b| {
b.iter(|| {
let allowed = bucket.check();
black_box(allowed);
});
});
group.bench_function("with_small_delay", |b| {
b.iter(|| {
std::thread::sleep(Duration::from_micros(1));
let allowed = bucket.check();
black_box(allowed);
});
});
group.finish();
}
/// Benchmark 7: Concurrent access to rate limiter
fn bench_concurrent_access(c: &mut Criterion) {
use std::thread;
let limiter = Arc::new(AtomicRateLimiter::new(1_000_000).with_user("user123"));
c.bench_function("concurrent_rate_limiter_4_threads", |b| {
b.iter(|| {
let mut handles = vec![];
for _ in 0..4 {
let limiter_clone = limiter.clone();
let handle = thread::spawn(move || {
for _ in 0..1000 {
black_box(limiter_clone.check("user123"));
}
});
handles.push(handle);
}
for handle in handles {
handle.join().expect("INVARIANT: Thread should complete successfully");
}
});
});
}
/// Benchmark 8: Deny rate (when limit exceeded)
fn bench_deny_rate(c: &mut Criterion) {
let limiter = AtomicRateLimiter::new(0).with_user("user123"); // Zero limit
c.bench_function("rate_limiter_deny_path", |b| {
b.iter(|| {
let allowed = limiter.check(black_box("user123"));
assert!(!allowed); // Should always deny
black_box(allowed);
});
});
}
/// Benchmark 9: Cache hit patterns
fn bench_cache_patterns(c: &mut Criterion) {
let mut group = c.benchmark_group("cache_hit_patterns");
// Hot path: Same user repeatedly (cache hit)
let limiter_hot = AtomicRateLimiter::new(1_000_000).with_user("user123");
group.bench_function("hot_path_same_user", |b| {
b.iter(|| {
let allowed = limiter_hot.check(black_box("user123"));
black_box(allowed);
});
});
// Cold path: Different users (cache miss simulation)
let mut limiter_cold = AtomicRateLimiter::new(1_000_000);
for i in 0..1000 {
limiter_cold = limiter_cold.with_user(&format!("user{}", i));
}
let mut counter = 0;
group.bench_function("cold_path_different_users", |b| {
b.iter(|| {
counter += 1;
let user_id = format!("user{}", counter % 1000);
let allowed = limiter_cold.check(&user_id);
black_box(allowed);
});
});
group.finish();
}
/// Benchmark 10: HFT scenario (100K requests/sec target)
fn bench_hft_scenario(c: &mut Criterion) {
let limiter = AtomicRateLimiter::new(100_000).with_user("hft_trader");
c.bench_function("hft_100k_rps_scenario", |b| {
b.iter_custom(|iters| {
let start = Instant::now();
for _ in 0..iters {
black_box(limiter.check("hft_trader"));
}
start.elapsed()
});
});
}
criterion_group!(
rate_limiting_benches,
bench_atomic_rate_limiter,
bench_token_bucket,
bench_sliding_window,
bench_user_scaling,
bench_burst_handling,
bench_refill_overhead,
bench_concurrent_access,
bench_deny_rate,
bench_cache_patterns,
bench_hft_scenario
);
criterion_main!(rate_limiting_benches);