Skip to content

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

Пользователь просил «1000 запросов в час», а получил «1000 запросов, потом час тишины от последней попытки». Разбор задачи в Better Auth, открытой девять месяцев, и почему её нельзя починить одной строкой.

v2 0 stars 0 forks 0 watchers 1 branch 0 runs Public
patched via APIv2
Как это должно было работать

Better Auth умеет ограничивать частоту запросов по ключу 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 запросов, а сброс — через сутки после последнего обращения.

Почему это ловушка, а не просто неудобство

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

code
1Фиксированное окно (как ждёт пользователь):
2 |--- час ---|--- час ---|--- час ---|
3 ^ сброс ^ сброс ^ сброс
4
5Окно от последнего запроса (как было):
6 запрос → сброс через час
7 запрос → сброс через час
8 запрос → сброс через час
9 …сброс не наступает никогда

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

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

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

Задача открыта в ноябре 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 нет.

Вот в чём дело. Чтобы окно перестало ездить, надо где-то хранить, когда оно открылось. Одного поля last_request_at для этого не хватает принципиально.

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

Мораль здесь не про библиотеку, а про цену ошибки в схеме данных: пока проект маленький, поле добавляется за минуту; когда вырастет — за девять месяцев.

Проверь у себя

1
Проверить, не продлевает ли отказ запрет

Самый быстрый способ — постучаться больше лимита, а потом подождать чуть меньше окна и постучать снова.

bash
1# упираемся в лимит
2for i in $(seq 1 10); do
3 curl -s -o /dev/null -w "%{http_code} " адрес
4done; echo
5
6# ждём чуть меньше окна, стучимся ещё пару раз,
7# ждём остаток окна и пробуем: должно разблокироваться

Если после полного окна от первого запроса всё ещё приходит 429 — окно у вас ездит.

В коде то же видно по одному признаку: запись времени обращения стоит до проверки лимита, а не после успешного прохода.

Why: Это единственная часть ограничителя, которую не видно в обычной жизни: пока лимит не исчерпан, оба варианта ведут себя одинаково. Разница проявляется только у того, кто уже заблокирован, — а он редко пишет в поддержку, он просто уходит.
Check
  • После полного окна от первого запроса доступ восстанавливается
  • Отклонённые запросы не сдвигают срок сброса
  • В ответе 429 есть чёткое «когда пробовать снова»
2
Сверить обещание настройки с тем, что она делаетRecommended

Пользователь в той задаче жалуется не только на поведение, но и на то, что документация обещала другое.

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

Пройдите по всем подписям к настройкам с числами и сроками и проверьте каждую руками. Одна неверная подпись стоит дороже одной неверной строки кода: код чинят, когда заметят, а по неверной подписи люди строят планы.

Why: Ошибка в обещании работает дольше ошибки в коде: код проверяется тестами, подпись — никем.
Check
  • Каждая подпись с числом или сроком проверена руками
  • Где поведение сложнее подписи — подпись уточнена
Что отсюда следует

Окно ограничения привязывается к первому событию, а не к последнему. Иначе получается не лимит, а наказание, которое продлевает себя само.

Отклонённая попытка не должна тратить квоту и сдвигать срок. Это отдельное решение, и его лучше закрепить тестом с прямым названием — вроде «отказ не продлевает запрет».

Структуру данных дешевле поменять сейчас. Если видно, что одного поля для честного поведения не хватает, второе поле добавляется за минуту — пока у вас одна установка, а не тысяча.

Разбор бага из плагина лояльности Medusa: кнопка «убрать скидку» выдавала максимальную. Одна строка, три исправления за три дня и верная проверка, лежавшая тремя строками ниже.

v2Public 0 0
updated Sep 1, 2026

Разбор настоящей истории из открытого проекта: четыре стратегии блокировки, эпитафия отвергнутой пятой и коммит «Go back to the old way of doing it» через полтора года. И чем платят за решение «пометить задачу взятой».

v2Public 0 0
updated Sep 1, 2026