Skip to content

Latest commit

 

History

History
474 lines (339 loc) · 9.4 KB

File metadata and controls

474 lines (339 loc) · 9.4 KB

Redis Rate Limiter

Redis-backed token bucket rate limiter for the API Gateway.

Features

  • Atomic operations via Lua scripts (no race conditions)
  • Multi-dimensional limits - per-minute and per-day
  • Burst allowance - handle traffic spikes gracefully
  • Efficient key design - minimal Redis memory usage
  • Automatic expiration - TTL-based cleanup
  • Fail-open strategy - graceful degradation on Redis failure

Architecture

Request → Auth Middleware → Rate Limit Middleware → Proxy
                                    ↓
                            [Redis Cluster]
                            ├─ Daily counter (TTL: 24h)
                            └─ Minute counter (TTL: 2min)

Rate Limit Algorithm

Token Bucket with Dual Limits

  1. Daily Hard Limit: Absolute maximum requests per day
  2. Minute Soft Limit: Target rate with burst allowance
  3. Burst Allowance: Additional requests beyond per-minute limit

Example (Premium Tier):

  • Base: 1,000 requests/minute
  • Burst: +500 additional requests
  • Daily cap: 100,000 requests

Traffic Pattern:

Minute 1: 1,500 requests → ✅ Allowed (using burst)
Minute 2: 1,501 requests → ❌ Rate limited
Minute 3: 800 requests   → ✅ Allowed (below base limit)

Redis Key Schema

ratelimit:org:{org_id}:daily:{YYYYMMDD}      → Counter (expires at midnight UTC)
ratelimit:org:{org_id}:minute:{unix_minute}  → Counter (expires after 2 minutes)

Example:

ratelimit:org:org_1:daily:20260125           → "1523"  (TTL: 14h32m)
ratelimit:org:org_1:minute:1737820800        → "47"    (TTL: 1m15s)

Memory Usage:

  • Per org per day: ~100 bytes (key + value + metadata)
  • 10,000 orgs: ~1 MB/day
  • With minute keys: ~2 MB total (auto-expires)

Lua Script Logic

check_limit.lua

-- Atomic rate limit check + increment
1. Get current daily count
2. Get current minute count
3. Check daily limit (hard stop)
4. Check minute limit + burst allowance
5. If both checks pass:
   - Increment both counters
   - Set TTL if first request
6. Return: {allowed, counts, reset times}

Why Lua?

  • Single network round-trip (vs 4+ with pipelining)
  • Atomic execution (no race conditions)
  • 10x faster than client-side logic

Performance:

  • P95 latency: <5ms
  • Throughput: 50K checks/sec per Redis instance

Integration Example

Basic Usage

import (
    "github.com/saas-gateway/gateway/internal/ratelimit"
)

// Initialize
redisClient, _ := ratelimit.NewRedisClient(ratelimit.RedisConfig{
    Addr: "localhost:6379",
})
limiter := ratelimit.NewRateLimiter(redisClient)

// Check limit
config := ratelimit.RateLimitConfig{
    RequestsPerMinute: 1000,
    RequestsPerDay:    100000,
    BurstAllowance:    500,
}

result, err := limiter.CheckLimit(ctx, "org_123", config)
if err != nil {
    // Handle error (fail open or closed)
}

if !result.Allowed {
    // Return 429 Too Many Requests
    return
}

// Proceed with request

With Middleware

// In main.go
rateLimitMiddleware := middleware.NewRateLimit(limiter)
apiRouter.Use(rateLimitMiddleware.Middleware)

Rate Limit Headers

The middleware adds standard rate limit headers to all responses:

HTTP/1.1 200 OK
X-RateLimit-Limit-Minute: 1000
X-RateLimit-Limit-Day: 100000
X-RateLimit-Remaining-Minute: 847
X-RateLimit-Remaining-Day: 95234
X-RateLimit-Reset-Minute: 2026-01-25T14:32:00Z
X-RateLimit-Reset-Day: 2026-01-26T00:00:00Z

When rate limited (429):

HTTP/1.1 429 Too Many Requests
Retry-After: 45
X-RateLimit-Remaining-Minute: 0

{
  "error": {
    "code": 429,
    "message": "Rate limit exceeded: minute limit reached",
    "details": {
      "limit_type": "minute",
      "daily_used": 1234,
      "minute_used": 1500,
      "reset_at": "2026-01-25T14:32:00Z",
      "retry_after": 45
    }
  },
  "timestamp": "2026-01-25T14:31:15Z",
  "request_id": "550e8400-e29b-41d4-a716-446655440000"
}

Configuration

Environment Variables

# Redis connection
REDIS_ADDR=localhost:6379
REDIS_PASSWORD=your_redis_password  # Optional
REDIS_DB=0                          # Database number (0-15)

Redis Setup (Docker)

# Start Redis
cd services/gateway
docker-compose up -d redis

# Verify
docker-compose ps
docker-compose logs redis

# Connect to Redis CLI
docker exec -it saas-gateway-redis redis-cli

Redis Commander (GUI)

# Start with tools profile
docker-compose --profile tools up -d

# Access at http://localhost:8081

Testing

Manual Testing

# 1. Start Redis
docker-compose up -d redis

# 2. Start gateway with Redis
export REDIS_ADDR=localhost:6379
export $(cat .env | xargs) && go run cmd/server/main.go

# 3. Make requests
for i in {1..10}; do
  curl -H "Authorization: Bearer sk_test_abc123" \
       http://localhost:8080/api/test \
       -w "\nStatus: %{http_code}\n"
done

# 4. Check rate limit headers
curl -i -H "Authorization: Bearer sk_test_abc123" \
     http://localhost:8080/api/test | grep X-RateLimit

Load Testing

# Install hey (HTTP load testing tool)
go install github.com/rakyll/hey@latest

# Test with 100 requests, 10 concurrent
hey -n 100 -c 10 \
    -H "Authorization: Bearer sk_test_abc123" \
    http://localhost:8080/api/test

# Expected output:
# - First ~1500 requests: 200 OK (limit + burst)
# - Remaining: 429 Too Many Requests

Unit Tests

# Run with Redis available
docker-compose up -d redis
go test ./internal/ratelimit/... -v

# Tests will be skipped if Redis is not available

Monitoring

Redis CLI Commands

# Connect to Redis
docker exec -it saas-gateway-redis redis-cli

# View all rate limit keys
KEYS ratelimit:*

# Get specific counter
GET ratelimit:org:org_1:daily:20260125

# Check TTL
TTL ratelimit:org:org_1:minute:1737820800

# Monitor in real-time
MONITOR

# Get memory usage
INFO memory

Key Metrics to Track

  1. Redis Memory Usage

    redis-cli INFO memory | grep used_memory_human
  2. Command Latency

    redis-cli --latency-history -i 1
  3. Rate Limited Requests

    • Count 429 responses in gateway logs
    • Track per organization
  4. Cache Hit Rate

    redis-cli INFO stats | grep keyspace

Scaling

Single Redis Instance

Capacity:

  • 50,000 checks/second
  • 10,000 active organizations
  • ~10 MB memory

When to scale:

  • Latency P95 > 10ms
  • Memory > 80%
  • CPU > 60%

Redis Cluster (Future)

For >100K RPS:

Client → [Proxy/Envoy]
            ↓
        Redis Cluster
        ├─ Shard 1 (hash slots 0-5461)
        ├─ Shard 2 (hash slots 5462-10922)
        └─ Shard 3 (hash slots 10923-16383)

Partitioning Strategy:

  • Hash slot by organization_id
  • Ensures all keys for an org on same shard
  • Linear scaling to 500K+ RPS

Failure Modes

Scenario 1: Redis Temporarily Unavailable

Current Behavior: Fail open (allow requests)

if err != nil {
    log.Printf("Rate limiter error: %v - failing open", err)
    next.ServeHTTP(w, r)  // Allow request
    return
}

Alternative: Fail closed (deny requests)

  • Better security but impacts availability
  • Enable with RATE_LIMIT_FAIL_CLOSED=true

Scenario 2: Redis Persistent Failure

Mitigation:

  1. In-memory fallback limiter (per-pod approximation)
  2. Alert to on-call engineer
  3. Auto-recovery when Redis returns

Scenario 3: Clock Skew Between Servers

Mitigation:

  • Use Redis TIME command for synchronization
  • TTL buffers (120s instead of 60s for minute keys)
  • NTP synchronization on all servers

Advanced Features (Future)

1. Distributed Rate Limiting

For multi-region deployments:

Region US-East → Redis US-East (primary)
                    ↓ (replication)
Region EU-West  → Redis EU-West (replica, read-only)

Trade-off: Eventual consistency (acceptable for rate limiting)

2. Dynamic Limits

Adjust limits based on:

  • Payment status
  • Usage tier upgrades
  • Promotional periods
// Override from database
limiter.CheckLimitWithOverride(ctx, orgID, dbLimits)

3. Per-Endpoint Limits

/api/heavy-operation → 10 req/min
/api/light-operation → 1000 req/min

Requires additional Redis keys:

ratelimit:org:{id}:endpoint:{path}:minute:{timestamp}

4. Smart Retry-After

Calculate optimal retry time based on usage pattern:

// Instead of fixed TTL
retryAfter := calculateOptimalRetry(result.MinuteCount, config.RequestsPerMinute)

Troubleshooting

High Redis Memory Usage

# Check key count
redis-cli DBSIZE

# Find large keys
redis-cli --bigkeys

# Set eviction policy
redis-cli CONFIG SET maxmemory-policy allkeys-lru

Slow Rate Limit Checks

# Check slow log
redis-cli SLOWLOG GET 10

# Monitor command latency
redis-cli --latency

Incorrect Counts

# Debug specific organization
redis-cli KEYS "ratelimit:org:org_1:*"
redis-cli GET ratelimit:org:org_1:daily:20260125

# Manual reset (admin only)
redis-cli DEL ratelimit:org:org_1:daily:20260125

Resources

Next Steps

Module 2.2: API Key Cache

  • Cache API keys in Redis (15-minute TTL)
  • Invalidate on revocation via pub/sub
  • Reduce PostgreSQL load by 99%