Перейти к содержимому

Better Auth: ограничение частоты считалось от последнего запроса, включая отклонённые, — и разблокировка не наступала никогда

v6 0 звёзд 0 форков 0 наблюдателей 1 ветка 0 прогонов Публичный
Шаг про отказ до записи: форма плагина с матчером, изъян со стороны провайдераv6

Better Auth, ограничение частоты для ключей API, ноябрь 2025 — по сей день.

Библиотека входа умеет ограничивать частоту запросов по ключу API: столько-то обращений за такое-то окно. Настройка выглядит понятно, и документация показывает пример «10 запросов в сутки».

Кадр первый: 17 ноября 2025

Приходит Xavier Mirabelli-Montan (xaviemirmon) и говорит, что это работает не так (#6035):

Because it's based on last_request_at it 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 она одна и та же:

code
1if (timeSinceLastRequest > rateLimitTimeWindow)

где timeSinceLastRequest — расстояние от последнего принятого обращения. Отклонённые запросы lastRequest не трогают, так что окно не отодвигают, — это важно, и ниже мы к этому вернёмся.

Ошибка обидна тем, что снаружи всё выглядит правильно: лимит срабатывает, счётчик считает, отказы приходят. Не работает только та часть, которую не видно, — возврат к нулю.

Кадр второй: 10 августа 2026, production

В задачу приходит Panagiotis Plytas (pplytas) с цифрами. Ключ с потолком 50 000 в час получает около 600 запросов в час —

about 1.2% of the configured allowance — yet gets rejected with RATE_LIMITED on 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 rateLimitResetAt field. 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 не влиты.

Чинится это не строчкой, а новым полем: чтобы окно перестало ездить, надо где-то хранить, когда оно открылось. А новое поле в библиотеке, которая стоит у тысяч людей, — это миграция у каждого из них. Отсюда и осторожность, и сроки, и то, что оба исправления написаны командой, а не пришли снаружи.

Что с этим делать у себя

1
Проверить, от чего считается окно — от первого запроса или от последнего

Ищите lastRequest, last_request_at, «обновить время при каждом обращении». Если окно считается от последнего принятого запроса, ровный поток под потолком никогда его не закроет — счётчик копится до лимита.

Зачем: Снаружи всё выглядит правильно: лимит срабатывает, отказы приходят. Не работает только то, чего не видно, — возврат к нулю. У pplytas это 1,2 % лимита и час отказов каждые трое суток.
Проверьте
  • Найдено, от какого события отсчитывается сброс
2
Прогнать ровный поток под потолком и убедиться, что он не накапливается

Потолок N за окно, запросы с шагом чуть больше окна ÷ N, часы подряд — и ни одного отказа. У Better Auth такой поток упирается в лимит через N запросов, у скользящего списка отправок — никогда.

Зачем: Это тест на модель окна, а не на счётчик. Тот, кто перепишет список на «последняя отправка плюс окно» ради экономии памяти, сломает его молча — как у них.
Проверьте
  • Есть тест вроде «ровный поток под потолком не накапливается»
3
Убедиться, что ОТКЛОНЁННАЯ попытка не продлевает запрет

Соседняя развилка: записывать ли отклонённую попытку. Самый быстрый способ проверить: упереться в лимит, постучаться ещё раз в середине окна и убедиться, что разблокировка наступила вовремя.

Зачем: Иначе человек, нажавший кнопку лишний раз, отодвигает себе вход ещё на окно. У Better Auth эта часть как раз в порядке — отказ `lastRequest` не трогает, и по этому признаку pplytas опознал ошибку в журналах.
Проверьте
  • Есть тест с прямым названием вроде «отказ не продлевает запрет»
4
Убедиться, что отказ приходит раньше, чем библиотека запишет новый код

Better Auth создаёт запись с кодом до вызова sendOTP, а проверяет по новейшей. Отказ из sendOTP оставляет в базе код, которого никто не получал, — и тот, что пришёл гостю, перестаёт подходить. Проверка должна стоять в before-хуке — удобнее всего своим плагином с матчером по пути, как делает и сам плагин телефона. Та же ловушка остаётся для отказа провайдера: SMS не ушла, а код уже новейший.

Зачем: Нашлось при ревью теста к этому разбору: три кода, четвёртый запрос — 429, третий код — INVALID_OTP. Воспроизвелось с первого раза.
Проверьте
  • Упереться в потолок и проверить, что последний полученный код по-прежнему подходит
  • Решено, что делать с записью, если провайдер отказал: удалить, или хотя бы не отвечать «код отправлен»
5
Сверить подпись к настройке с тем, что она делаетРекомендуется

«10 запросов в сутки» и «10 запросов, потом сутки тишины от последней попытки» — разные продукты.

Зачем: Пользователь в задаче жалуется не только на поведение, но и на то, что документация обещала другое. Обещание — тоже часть продукта.
Проверьте
  • Проверено, что при отказе приходит понятный ответ, а не молчаливое «готово»
6
Посчитать, во что обойдётся починка позжеОпционально

Здесь чинится не строчкой, а новым полем — значит миграцией у каждой установки. Если ваш счётчик живёт в памяти одного процесса, починка — правка одной функции; если в базе у тысяч пользователей — разговор на год.

Зачем: Цена ошибки в модели данных растёт с числом установок, а не со сложностью кода.
Проверьте
  • Записано, что придётся менять, если правило окна придётся поменять
Кто в кадре
  • 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 выяснилось, что окно двигают только принятые запросы, а ловушка в другом: ровный поток под потолком копится до лимита.

Как проект живёт и принимает чужие правки, и кто в нём встречался в разборах — по поступкам, датам и ссылкам

обновлён 2 сент. 2026 г.

Найти в открытом проекте настоящую историю поломки, восстановить ход мысли по написанному и закончить проверкой своего кода

обновлён 1 сент. 2026 г.

k8sgpt отправляет поломки кластера языковой модели и обещает анонимизацию. Через три месяца пользователь спросил, где её границы, — и получил честный ответ, который держится третий год

обновлён 3 сент. 2026 г.

Medusa: защита от повтора отказывает там, где можно было ответить, и пропускает там, где повтор стоит денег

обновлён 2 сент. 2026 г.

Medusa: кнопка «убрать скидку» выдавала максимальную, потому что ноль в JavaScript ложный

обновлён 2 сент. 2026 г.

Saleor: товар сняли с продажи — и строку из корзины покупателя стирали в фоне. Три с половиной года до теста, поменявшего знак

обновлён 2 сент. 2026 г.