Rate limiter на API-эндпоинте
Предпосылки: Клиенты и соединения, STRING.
Биллинг-система выставляет партнёрам лимит — 100 запросов в минуту. При превышении API возвращает 429 Too Many Requests. Требования: атомарный инкремент счётчика, автоматический сброс по окончании окна, минимальная задержка на каждый запрос.
HASH не подходит: нет TTL на отдельное поле, нельзя атомарно установить TTL при первом инкременте. LIST не подходит: нет атомарного INCR, подсчёт длины — это отдельная команда. STRING с INCR + EXPIRE решает задачу:
class RateLimiter
LIMIT = 100
WINDOW = 60 # секунд
def initialize(redis_pool)
@redis_pool = redis_pool
end
def allow?(partner_id)
key = "ratelimit:#{partner_id}:#{current_window}"
@redis_pool.with do |r|
count = r.incr(key)
r.expire(key, WINDOW) if count == 1 # TTL только при создании ключа
count <= LIMIT
end
end
private
def current_window
Time.now.to_i / WINDOW
end
endINCR на несуществующем ключе создаёт его со значением 1 — отдельного SET не нужно. EXPIRE вызывается только при count == 1, т.е. при первом запросе в окне. Все последующие инкременты не трогают TTL. Через 60 секунд ключ исчезает, и счётчик начинается заново.
INCR и EXPIRE — две отдельные команды, а не одна атомарная операция. В начале нового окна выполняются обе (два round-trip), в последующих запросах — только INCR (один round-trip). Если процесс упадёт после INCR, но до EXPIRE, ключ останется без TTL и счётчик не сбросится. На практике окно между двумя вызовами — микросекунды, и сценарий крайне редок. Если гарантия важна, можно обернуть пару в Lua-скрипт или вызывать EXPIRE для каждого запроса (идемпотентно, но лишний round-trip).
При 10 000 партнёров в памяти одновременно находится максимум 10 000 ключей по ~100 байт — меньше мегабайта.