Skip to content

Окно, которое всё время отодвигалось

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

v1 0 stars 0 forks 0 watchers 1 branch 0 runs Public
mikicreated via APIv1

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

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

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

Приходит Ксавье Эмирмон (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 запросов, а потом час тишины, считая от последней попытки» — то есть при живом трафике час не наступает никогда.

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

Девять месяцев и две попытки

Задача открыта в ноябре 2025-го и по состоянию на сентябрь 2026-го всё ещё открыта. Исправление писали дважды: PR #8803 в марте 2026-го, потом #10747 в августе — его прислал разработчик под ником bytaesu, и он прямо говорит, что заменяет первый. К моменту этой записи не влит и он.

Причина видна в шапке самого PR:

This adds the nullable rateLimitResetAt field. It requires a database schema migration but does not introduce a breaking API change.

Добавляется поле rateLimitResetAt. Требуется миграция схемы, но ломающих изменений в API нет.

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

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

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

Ищите last_request_at, lastSeen, «обновить время при каждом обращении». Окно, привязанное к последнему запросу, ездит вперёд само.

Why: Снаружи всё выглядит правильно: лимит срабатывает, отказы приходят. Не работает только то, чего не видно, — возврат к жизни.
Check
  • Найдено, от какого события отсчитывается сброс
2
Убедиться, что ОТКЛОНЁННАЯ попытка не продлевает запрет

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

Why: Иначе человек, нажавший кнопку лишний раз, отодвигает себе вход — и так до бесконечности. При живом трафике окно не закроется никогда.
Check
  • Есть тест с прямым названием вроде «отказ не продлевает запрет»
3
Сверить подпись к настройке с тем, что она делаетRecommended

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

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

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

Why: Цена ошибки в модели данных растёт с числом установок, а не со сложностью кода.
Check
  • Записано, что придётся менять, если правило окна придётся поменять

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

v4Public 0 0
updated Sep 1, 2026

graphile-worker: очередь на Postgres, замеры в комментариях и настройка, не пережившая полутора лет

v2Public 0 0
updated Sep 1, 2026

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

v1Public 0 0
updated Sep 1, 2026

Saleor: три человека за три года просят денег без копеек, потому что таких монет нет в обращении

v1Public 0 0
updated Sep 1, 2026

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

v1Public 0 0
updated Sep 1, 2026

Как Cal.com год чинил расписание, переходящее за полночь, и чем это кончилось

v2Public 0 0
updated Sep 1, 2026