Короткий ответ
Middleware должен на каждый запрос проверять и увеличивать счётчик обращений по ключу (например, IP или ID пользователя) с ограниченным временным окном в общем для всех инстансов хранилище (обычно Redis с TTL/атомарными операциями), а не в локальной памяти процесса, иначе лимит не будет общим при горизонтальном масштабировании.
Как это работает подробнее
Типичная реализация — алгоритм fixed window или sliding window/token bucket: для каждого ключа хранится счётчик запросов за текущее окно времени; при превышении лимита middleware возвращает 429 Too Many Requests, обычно с заголовком Retry-After. Атомарность инкремента важна при конкурентных запросах от одного клиента — обычная проверка-потом-инкремент в Python без блокировки/атомарной операции в хранилище подвержена race condition.
Пример кода
import time
import redis
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
app = FastAPI()
r = redis.Redis()
LIMIT = 100
WINDOW_SECONDS = 60
@app.middleware("http")
async def rate_limit(request: Request, call_next):
client_id = request.client.host
key = f"rl:{client_id}:{int(time.time() // WINDOW_SECONDS)}"
count = r.incr(key)
if count == 1:
r.expire(key, WINDOW_SECONDS)
if count > LIMIT:
return JSONResponse(status_code=429, content={"error": "rate_limited"}, headers={"Retry-After": str(WINDOW_SECONDS)})
return await call_next(request)Что использовать на практике
- Практика. Хранение в памяти процесса (
dictв Python) работает только для одного процесса/инстанса — при нескольких воркерах/подах лимит фактически умножается на их количество. Redis сINCR+EXPIREили библиотеки типаdjango-ratelimit/сторонние middleware для FastAPI решают это за счёт общего хранилища.
Подводные камни
- Риск. Реализуют rate limiting на уровне одного процесса без учёта горизонтального масштабирования и обнаруживают проблему только в production при увеличении числа инстансов; забывают про
Retry-Afterв ответе, из-за чего клиентам приходится угадывать, когда повторить запрос.