๐Ÿ“ฆ EqualifyEverything / equalify-reflow

๐Ÿ“„ rate-limits.md ยท 116 lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116# Rate limits reference

Configured limits, Redis key patterns, and response headers. For the why, see [rate limiting design](../explanation/rate-limiting.md).

## Configured tiers

| Tier | Scope | Limit | Window | Purpose |
|---|---|---|---|---|
| Per-IP submission | `X-Forwarded-For` first IP | 10 | 1 hour | Individual abuse / runaway scripts |
| Per-IP status check | `X-Forwarded-For` first IP | 100 | 1 hour | Aggressive polling |
| Global submission | System-wide | 1000 | 24 hours | Cost ceiling |

All tiers return **HTTP 429** with a `Retry-After` header on violation. Adjust values in `src/services/rate_limit_service.py`:

```python
class RateLimitService:
    SUBMIT_PER_IP_LIMIT = 10        # Submissions per hour per IP
    SUBMIT_PER_IP_WINDOW = 3600

    STATUS_PER_IP_LIMIT = 100       # Status checks per hour per IP
    STATUS_PER_IP_WINDOW = 3600

    GLOBAL_SUBMIT_LIMIT = 1000      # Global submissions per day
    GLOBAL_SUBMIT_WINDOW = 86400
```

## Response headers

Every rate-limited response includes:

```http
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 7
X-RateLimit-Reset: 1704124800
```

On 429:

```http
HTTP/1.1 429 Too Many Requests
Retry-After: 300
X-RateLimit-Remaining: 0

{
  "detail": "Rate limit exceeded for submission",
  "retry_after": 300,
  "limit_type": "submission"
}
```

## Exempt endpoints

The following bypass rate limiting:

- `/` โ€” viewer SPA
- `/health`, `/health/ready` โ€” load-balancer checks
- `/docs`, `/redoc`, `/openapi.json` โ€” API documentation
- `/metrics` โ€” Prometheus scrape

## Redis keys

```
eq-pdf:ratelimit:submit:ip:{client_ip}
eq-pdf:ratelimit:status:ip:{client_ip}
eq-pdf:ratelimit:submit:global
```

Each key is a sorted set with timestamp scores and an `EXPIRE` cleanup TTL matching the window. Example payload:

```
ZADD eq-pdf:ratelimit:submit:ip:192.168.1.1 1704120000.123 "req-1"
ZADD eq-pdf:ratelimit:submit:ip:192.168.1.1 1704120005.456 "req-2"
EXPIRE eq-pdf:ratelimit:submit:ip:192.168.1.1 3600
```

## Client IP detection

Priority order in the middleware:

1. `X-Forwarded-For` โ€” first IP in the chain
2. `X-Real-IP`
3. `request.client.host` โ€” direct connection fallback

In production, ensure the ALB or reverse proxy sets trusted `X-Forwarded-For` headers.

## Environment variables

```bash
REDIS_URL=redis://redis:6379        # Required
REDIS_MAX_CONNECTIONS=10
```

## Administrative operations

```python
from src.services.rate_limit_service import RateLimitService

# Reset a tier for a specific IP
await rate_limiter.reset_rate_limit("192.168.1.100", "submit")
await rate_limiter.reset_rate_limit("192.168.1.100", "status")

# Check remaining quota
quota = await rate_limiter.get_remaining_quota("192.168.1.100", "submit")
# โ†’ {"limit": 10, "remaining": 3, "reset_at": 1704124800, "window_seconds": 3600}
```

## Implementation

| File | Role |
|---|---|
| `src/services/rate_limit_service.py` | Sliding-window logic, Redis operations |
| `src/middleware/rate_limit.py` | FastAPI middleware, fail-open handling |
| `src/dependencies.py::get_rate_limit_service` | DI factory |
| `tests/unit/services/test_rate_limit_service.py` | Algorithm tests |
| `tests/unit/middleware/test_rate_limit.py` | Middleware tests (exemption, fail-open) |