Окно, которое всё время отодвигалось
Better Auth: ограничение частоты считалось от последнего запроса, включая отклонённые, — и разблокировка не наступала никогда
miki/okno-kotoroe-vse-vremya-otodvigalos · v6
Better Auth: ограничение частоты считалось от последнего запроса, включая отклонённые, — и разблокировка не наступала никогда
Better Auth, ограничение частоты для ключей API, ноябрь 2025 — по сей день.
Библиотека входа умеет ограничивать частоту запросов по ключу API: столько-то обращений за такое-то окно. Настройка выглядит понятно, и документация показывает пример «10 запросов в сутки».
Приходит Xavier Mirabelli-Montan (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 запросов подряд, хоть за неделю, — а потом час тишины».
В коде это одна строка, и в версии 1.3.7 и в main она одна и та же:
где timeSinceLastRequest — расстояние от последнего принятого обращения. Отклонённые запросы lastRequest не трогают, так что окно не отодвигают, — это важно, и ниже мы к этому вернёмся.
Ошибка обидна тем, что снаружи всё выглядит правильно: лимит срабатывает, счётчик считает, отказы приходят. Не работает только та часть, которую не видно, — возврат к нулю.
В задачу приходит Panagiotis Plytas (pplytas) с цифрами. Ключ с потолком 50 000 в час получает около 600 запросов в час —
about 1.2% of the configured allowance — yet gets rejected with
RATE_LIMITEDon a regular cycle.
около 1,2 % от разрешённого — и всё равно регулярно получает отказ.
Из журналов видна подпись ошибки: всплески отказов «lasting almost exactly one rateLimitTimeWindow (59.9, 59.9, 59.9, 58.6 minutes)», повторяющиеся каждые 66–81 часов — это как раз 50 000 делить на 600 в час. Между всплесками requestCount растёт непрерывно, а lastRequest двигается вперёд с каждым принятым запросом. Отсюда и час тишины:
consistent with denied requests not advancing
lastRequest, so recovery takes one full idle window from the last accepted request.
согласуется с тем, что отклонённые запросы lastRequest не двигают, и восстановление занимает полное окно простоя от последнего принятого.
Отдельно воспроизведено в изоляции: окно две секунды, потолок пять, один запрос каждые 600 мс — всегда под лимитом — и «verification settles into a periodic accept-5 / deny-3 / reset cycle». Три версии сверены: «the file is byte-identical across all three». Четыре вопроса команде; ответа на 02.09.2026 нет.
Первый ответ пришёл через 26 секунд — от бота проекта, и с верным диагнозом. Человек из команды — в тот же день. Задачу назначили в январе, метки и веху пересобрали в конце марта, и 27 марта Maxwell (ping-maxwell) из команды открыл #8803: новое поле rateLimitWindowStart, метка breaking, ветка next. Бот проверил за три минуты; ревью от человека не было ни одного. Автор задачи спрашивал в мае дважды и в августе:
congrats on the 1.7 launch. Are there any plans to merge this in now that the conflicts have been resolved?
1 августа бот-секретарь пометил задачу как заброшенную и пообещал закрыть через семь дней. Через два часа:
This is still an issue that has been holding my team back for months.
Пометку сняли. 10 августа Taesu (bytaesu) из той же команды прислал #10747 — «Replaces #8803». Причина видна в шапке:
This adds the nullable
rateLimitResetAtfield. It requires a database schema migration but does not introduce a breaking API change.
Добавляется поле rateLimitResetAt, допускающее пустое значение. Требуется миграция схемы, но ломающих изменений в API нет.
Maxwell одобрил через двадцать минут: «LGTM, mostly looks the same as the original PR». Через три дня автор перевёл PR в черновик: «Target v1.8». 18 августа вышла 1.7.0 — без него. На 02.09.2026 задача открыта, оба PR не влиты.
Чинится это не строчкой, а новым полем: чтобы окно перестало ездить, надо где-то хранить, когда оно открылось. А новое поле в библиотеке, которая стоит у тысяч людей, — это миграция у каждого из них. Отсюда и осторожность, и сроки, и то, что оба исправления написаны командой, а не пришли снаружи.
Что с этим делать у себя
Ищите lastRequest, last_request_at, «обновить время при каждом обращении». Если окно считается от последнего принятого запроса, ровный поток под потолком никогда его не закроет — счётчик копится до лимита.
- Найдено, от какого события отсчитывается сброс
Потолок N за окно, запросы с шагом чуть больше окна ÷ N, часы подряд — и ни одного отказа. У Better Auth такой поток упирается в лимит через N запросов, у скользящего списка отправок — никогда.
- Есть тест вроде «ровный поток под потолком не накапливается»
Соседняя развилка: записывать ли отклонённую попытку. Самый быстрый способ проверить: упереться в лимит, постучаться ещё раз в середине окна и убедиться, что разблокировка наступила вовремя.
- Есть тест с прямым названием вроде «отказ не продлевает запрет»
Better Auth создаёт запись с кодом до вызова sendOTP, а проверяет по новейшей. Отказ из sendOTP оставляет в базе код, которого никто не получал, — и тот, что пришёл гостю, перестаёт подходить. Проверка должна стоять в before-хуке — удобнее всего своим плагином с матчером по пути, как делает и сам плагин телефона. Та же ловушка остаётся для отказа провайдера: SMS не ушла, а код уже новейший.
- Упереться в потолок и проверить, что последний полученный код по-прежнему подходит
- Решено, что делать с записью, если провайдер отказал: удалить, или хотя бы не отвечать «код отправлен»
«10 запросов в сутки» и «10 запросов, потом сутки тишины от последней попытки» — разные продукты.
- Проверено, что при отказе приходит понятный ответ, а не молчаливое «готово»
Здесь чинится не строчкой, а новым полем — значит миграцией у каждой установки. Если ваш счётчик живёт в памяти одного процесса, починка — правка одной функции; если в базе у тысяч пользователей — разговор на год.
- Записано, что придётся менять, если правило окна придётся поменять
- xaviemirmon — Xavier Mirabelli-Montan, Брайтон. Завёл #6035; за девять месяцев спрашивал четыре раза.
- ping-maxwell — Maxwell, команда Better Auth. Ответил в день открытия, назначен на задачу в январе, автор #8803, одобрил #10747.
- bytaesu — Taesu, Сеул, команда Better Auth. Автор #10747; сам перевёл его в черновик: «Target v1.8».
- gustavovalverde — Gustavo Valverde. Пересобрал метки задачи, сменил базовую ветку #8803; последним трогал
rate-limit.ts(#9993). - pplytas — Panagiotis Plytas, Афины. Принёс измерения из production и воспроизведение в изоляции.
- better-auth-agent, Dosu, cubic — боты: первый поставил диагноз за 26 секунд, второй чуть не закрыл задачу, третий проверил #8803 за три минуты.
Подробнее о проекте — в карточке «Better Auth: повадки проекта».
Исправлено 02.09.2026: раньше здесь было написано, что отклонённые запросы тоже отодвигают окно. Это неверно — при перепроверке по коду версий 1.3.7 и main и по данным pplytas выяснилось, что окно двигают только принятые запросы, а ловушка в другом: ровный поток под потолком копится до лимита.
Связанные списки
Как проект живёт и принимает чужие правки, и кто в нём встречался в разборах — по поступкам, датам и ссылкам
Найти в открытом проекте настоящую историю поломки, восстановить ход мысли по написанному и закончить проверкой своего кода
k8sgpt отправляет поломки кластера языковой модели и обещает анонимизацию. Через три месяца пользователь спросил, где её границы, — и получил честный ответ, который держится третий год
Medusa: защита от повтора отказывает там, где можно было ответить, и пропускает там, где повтор стоит денег
Medusa: кнопка «убрать скидку» выдавала максимальную, потому что ноль в JavaScript ложный
