Окно, которое всё время отодвигалось
Better Auth: ограничение частоты считалось от последнего запроса, включая отклонённые, — и разблокировка не наступала никогда
miki/okno-kotoroe-vse-vremya-otodvigalos · v1
Better Auth: ограничение частоты считалось от последнего запроса, включая отклонённые, — и разблокировка не наступала никогда
Better Auth, ограничение частоты для ключей API, ноябрь 2025 — по сей день.
Библиотека входа умеет ограничивать частоту запросов по ключу API: столько-то обращений за такое-то окно. Настройка выглядит понятно, и документация показывает пример «10 запросов в сутки».
Приходит Ксавье Эмирмон (xaviemirmon) и говорит, что это работает не так (#6035):
Because it's based on
last_request_atit waits until the number of requests has been reached then will wait the time set and refresh.
Поскольку всё считается от последнего запроса, оно ждёт, пока не наберётся нужное число обращений, а потом отсчитывает окно заново.
И дальше — фраза, ради которой эту задачу стоит читать целиком:
The docs are confusing as it make it appear that every day the user has 10 requests per day. In reality they have 10 requests but have to wait a day from the last request for it to reset.
Документация вводит в заблуждение: кажется, что у пользователя 10 запросов в сутки. На самом деле у него 10 запросов, а сброс — через сутки после последнего обращения.
Окно привязано к последнему запросу, а не к первому. Значит каждое обращение сдвигает срок сброса вперёд — включая отклонённые. Клиент, который упёрся в лимит и продолжает стучаться, отодвигает собственную разблокировку. Бесконечно.
Пользователю нужно было «1000 в час». Он получил «1000 запросов, а потом час тишины, считая от последней попытки» — то есть при живом трафике час не наступает никогда.
Ошибка обидна тем, что снаружи всё выглядит правильно: лимит срабатывает, счётчик считает, отказы приходят. Не работает только та часть, которую не видно, — возврат к жизни.
Задача открыта в ноябре 2025-го и по состоянию на сентябрь 2026-го всё ещё открыта. Исправление писали дважды: PR #8803 в марте 2026-го, потом #10747 в августе — его прислал разработчик под ником bytaesu, и он прямо говорит, что заменяет первый. К моменту этой записи не влит и он.
Причина видна в шапке самого PR:
This adds the nullable
rateLimitResetAtfield. It requires a database schema migration but does not introduce a breaking API change.
Добавляется поле rateLimitResetAt. Требуется миграция схемы, но ломающих изменений в API нет.
Чинится это не строчкой, а новым полем: чтобы окно перестало ездить, надо где-то хранить, когда оно открылось. А новое поле в библиотеке, которая стоит у тысяч людей, — это миграция у каждого из них. Отсюда и осторожность, и сроки.
Что с этим делать у себя
Ищите last_request_at, lastSeen, «обновить время при каждом обращении». Окно, привязанное к последнему запросу, ездит вперёд само.
- Найдено, от какого события отсчитывается сброс
Самый быстрый способ: упереться в лимит, постучаться ещё раз в середине окна и проверить, что разблокировка наступила вовремя.
- Есть тест с прямым названием вроде «отказ не продлевает запрет»
«10 запросов в сутки» и «10 запросов, потом сутки тишины от последней попытки» — разные продукты.
- Проверено, что при отказе приходит понятный ответ, а не молчаливое «готово»
Здесь чинится не строчкой, а новым полем — значит миграцией у каждой установки. Если ваш счётчик живёт в памяти одного процесса, починка — правка одной функции; если в базе у тысяч пользователей — разговор на год.
- Записано, что придётся менять, если правило окна придётся поменять
Related lists
Найти в открытом проекте настоящую историю поломки, восстановить ход мысли по написанному и закончить проверкой своего кода
graphile-worker: очередь на Postgres, замеры в комментариях и настройка, не пережившая полутора лет
Medusa: защита от повтора отказывает там, где можно было ответить, и пропускает там, где повтор стоит денег
Saleor: три человека за три года просят денег без копеек, потому что таких монет нет в обращении
Medusa: кнопка «убрать скидку» выдавала максимальную, потому что ноль в JavaScript ложный
Как Cal.com год чинил расписание, переходящее за полночь, и чем это кончилось

