Окно, которое всё время отодвигалось: ограничение частоты, привязанное не к тому событию
Пользователь просил «1000 запросов в час», а получил «1000 запросов, потом час тишины от последней попытки». Разбор задачи в Better Auth, открытой девять месяцев, и почему её нельзя починить одной строкой.
miki/okno-kotoroe-vse-vremya-otodvigalos-ogranichenie-chastoty-pr · v2
Пользователь просил «1000 запросов в час», а получил «1000 запросов, потом час тишины от последней попытки». Разбор задачи в Better Auth, открытой девять месяцев, и почему её нельзя починить одной строкой.
Better Auth умеет ограничивать частоту запросов по ключу API: столько-то обращений за такое-то окно.
Настройка выглядит понятно, и документация показывает пример вроде «10 запросов в сутки». Каждый, кто видел такую настройку, понимает её одинаково: сутки прошли — счётчик обнулился.
17 ноября 2025 года приходит пользователь — Ксавье Эмирмон (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 запросов, а сброс — через сутки после последнего обращения.
Окно привязано к последнему запросу, а не к первому. Значит каждое обращение сдвигает срок сброса вперёд — включая отклонённые.
Клиент, который упёрся в лимит и продолжает стучаться, отодвигает собственную разблокировку. Бесконечно. А ретрай с повторами — ровно то, что делает любой нормальный клиент.
Снаружи всё выглядит правильно: лимит срабатывает, счётчик считает, отказы приходят. Не работает только та часть, которую не видно, — возврат к жизни.
Задача открыта в ноябре 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 для этого не хватает принципиально.
А новое поле в библиотеке, которая стоит у тысяч людей, — это миграция у каждого из них. Отсюда и осторожность, и сроки.
Мораль здесь не про библиотеку, а про цену ошибки в схеме данных: пока проект маленький, поле добавляется за минуту; когда вырастет — за девять месяцев.
Проверь у себя
Самый быстрый способ — постучаться больше лимита, а потом подождать чуть меньше окна и постучать снова.
Если после полного окна от первого запроса всё ещё приходит 429 — окно у вас ездит.
В коде то же видно по одному признаку: запись времени обращения стоит до проверки лимита, а не после успешного прохода.
- После полного окна от первого запроса доступ восстанавливается
- Отклонённые запросы не сдвигают срок сброса
- В ответе 429 есть чёткое «когда пробовать снова»
Пользователь в той задаче жалуется не только на поведение, но и на то, что документация обещала другое.
«10 запросов в сутки» и «10 запросов, потом сутки тишины от последней попытки» — разные продукты.
Пройдите по всем подписям к настройкам с числами и сроками и проверьте каждую руками. Одна неверная подпись стоит дороже одной неверной строки кода: код чинят, когда заметят, а по неверной подписи люди строят планы.
- Каждая подпись с числом или сроком проверена руками
- Где поведение сложнее подписи — подпись уточнена
Окно ограничения привязывается к первому событию, а не к последнему. Иначе получается не лимит, а наказание, которое продлевает себя само.
Отклонённая попытка не должна тратить квоту и сдвигать срок. Это отдельное решение, и его лучше закрепить тестом с прямым названием — вроде «отказ не продлевает запрет».
Структуру данных дешевле поменять сейчас. Если видно, что одного поля для честного поведения не хватает, второе поле добавляется за минуту — пока у вас одна установка, а не тысяча.
Related lists
Разбор бага из плагина лояльности Medusa: кнопка «убрать скидку» выдавала максимальную. Одна строка, три исправления за три дня и верная проверка, лежавшая тремя строками ниже.
Разбор настоящей истории из открытого проекта: четыре стратегии блокировки, эпитафия отвергнутой пятой и коммит «Go back to the old way of doing it» через полтора года. И чем платят за решение «пометить задачу взятой».
