У пользователя остался один запрос к ИИ. Он нажимает кнопку «Улучшить текст», не дожидается ответа, раздражённо отправляет второе сообщение, и оба обновления приходят в бота почти одновременно. Два обработчика читают из базы одно и то же число: остаток равен единице. Оба решают, что запрос разрешён. Оба отправляют задание в очередь. В итоге пользователь потратил два запроса, а у вас записано, что он использовал один. Это не экзотика, а самая обычная гонка, и именно с неё стоит начинать разговор о тарифных ограничениях.

Если ограничения построены на фразе «прочитали остаток, проверили, выполнили, списали», они держатся только до первого нетерпеливого пользователя. Ниже разберём, как устроить систему, в которой право на функцию, доступный остаток и реальный расход не смешиваются, а конкурентные запросы не делят один и тот же остаток дважды.

Три вопроса вместо одного «есть ли подписка»

Первая ошибка в тарифных ботах — одно булево поле is_premium. Оно отвечает на вопрос, который в действительности состоит из трёх разных.

  • Активна ли подписка? Это состояние договорённости: оплачено, действует до такой-то даты, не отменено.
  • Доступна ли функция на этом тарифе? Это таблица возможностей: генерация изображений есть на старшем плане, длинные документы нет на базовом, анализ файлов только по подписке.
  • Хватает ли квоты? Это счётчик, который уменьшается и пополняется: запросы в день, токены в месяц, одновременные задания.

Эти три слоя меняются с разной скоростью и по разным причинам. Подписка меняется платежом или истечением срока. Таблица возможностей меняется релизом, когда вы решили перенести функцию с одного плана на другой. Квота меняется при каждом обращении к модели. Если слить их в одно поле, любое изменение одного слоя будет ломать два других. Например, при смене состава тарифа придётся перепроверять всех подписчиков, а при продлении вы случайно обнулите расходованный остаток или, наоборот, забудете его сбросить.

Практичная схема хранения выглядит так. Таблица подписок содержит пользователя, код тарифа, начало и конец периода, статус. Справочник тарифов связывает код плана с набором функций и размерами лимитов. Отдельная таблица квот хранит для пользователя и периода выделенный объём, зарезервированный объём и израсходованный объём. Из них вычисляется доступный остаток: выделено минус зарезервировано минус израсходовано. Слово «зарезервировано» здесь ключевое, к нему мы вернёмся.

Единица измерения квоты

Прежде чем считать остаток, нужно решить, в чём он измеряется. Для ИИ-функций есть три разумных варианта, и у каждого своя цена простоты.

Чем измерять квоту пользователя
ЕдиницаЧто удобноЧто неудобно
Количество обращенийПользователь понимает без объяснений; легко резервировать ровно одну единицуКороткий и очень длинный запрос стоят вам по-разному, а считаются одинаково
Токены моделиБлиже к вашему реальному расходуТочное число известно только после ответа; пользователю трудно оценить заранее
Условные кредитыМожно задать разную цену функциям: текст дешевле, изображение дорожеНужно самим поддерживать таблицу цен и объяснять её

Для старта часто хватает кредитов с простой таблицей: обычный ответ стоит одну единицу, анализ длинного файла три, и так далее. Но принципы ниже одинаково работают для любой единицы. Важно лишь помнить: Stars, которыми платят за подписку, и токены модели — разные величины. Курса «одна Star равна стольким-то токенам» не существует, это ваша бизнес-модель. Вы сами решаете, сколько кредитов входит в тариф, и сами следите, чтобы стоимость обращений к модели не съедала выручку.

Право появляется только после подтверждённой оплаты

Теперь о том, откуда берётся активная подписка. Цифровые товары внутри Telegram оплачиваются Stars, и порядок событий здесь имеет значение: выдавать товар следует после successful_payment, а не просто после pre_checkout. Об этом говорит документация Telegram по оплате цифровых товаров. Там же сказано, что идентификатор оплаты нужен для возврата, поэтому его стоит сохранить.

Логика различий простая. pre_checkout — это вопрос Telegram к боту: готов ли ты принять этот заказ? Ответ «да» ещё не означает, что деньги списаны. Сообщение successful_payment приходит, когда платёж завершён. Если активировать тариф на первом этапе, пользователь, у которого оплата сорвалась, получит доступ даром.

Для квот отсюда следуют три практических правила.

  1. Активация подписки — отдельная запись, привязанная к идентификатору платежа. Сохраните идентификатор оплаты в таблице платежей и сделайте его уникальным. Тогда повторная доставка одного и того же обновления не создаст вторую подписку и не выдаст квоту дважды. Это обычная защита от дублей: бот не должен предполагать, что каждое событие придёт ровно один раз.
  2. Выдача квоты и смена статуса происходят одной транзакцией вашей базы. Сохранили платёж, создали период подписки, начислили квоту — всё или ничего. Эта транзакция охватывает только вашу базу; Telegram в ней не участвует, и если вы обработали платёж, а подтвердить обработку не удалось, вам придётся уметь безопасно принять то же событие снова.
  3. Возврат отзывает право через тот же идентификатор. Если платёж возвращён, запись по нему меняет статус, а действующие резервы и остатки обрабатываются по заранее принятому правилу. Какому именно — зависит от вашей политики, и о ней мы поговорим в разделе про отмену.

Заметьте: пока мы не обсуждали ИИ вообще. Это не случайно. Сначала надо быть уверенным, что слой «подписка активна» отражает реальность, и лишь затем строить на нём слой квот.

Гонка за последнюю единицу

Вернёмся к пользователю с одним оставшимся запросом. Покажем на цифрах, почему наивная схема ломается. Условный пример: остаток равен 1, приходят два сообщения, A и B.

Наивная проверка без резервирования
ШагОбработчик AОбработчик BОстаток в базе
1Читает остаток: 11
2Читает остаток: 11
31 > 0, ставит задачу в очередь1 > 0, ставит задачу в очередь1
4После ответа списывает 1После ответа списывает 1−1 или 0, смотря как написан UPDATE

Обе задачи прошли проверку, потому что каждая смотрела на остаток до того, как другая его изменила. Проблема не в медленной базе и не в плохом коде обработчика. Проблема в разрыве между проверкой и изменением: между шагами 1 и 4 проходит время, за которое остаток успевает «устареть». Чем дольше работает модель, тем шире окно. Ответ ИИ может занять секунды, а иногда десятки секунд, и всё это время остаток выглядит нетронутым.

Решение — не проверять, а резервировать: превращать проверку и уменьшение доступного остатка в одну неделимую операцию до постановки в очередь. Если резерв не удался, задачи просто нет.

Как сделать резерв неделимым

PostgreSQL предоставляет механизмы блокировок для управления конкурентным доступом, а текущее состояние блокировок можно увидеть через представление pg_locks, как описано в разделе документации о явных блокировках. Для нашей задачи есть два практичных пути, и оба сводятся к тому, чтобы у каждого пользователя в любой момент был один «победитель».

Условное обновление одной строкой. Вместо «прочитать, потом записать» делаем единый запрос, в котором условие проверяется в момент записи. Идея выглядит так:

UPDATE quota
SET reserved = reserved + :cost
WHERE user_id = :user_id
  AND period_id = :period_id
  AND granted - reserved - used >= :cost;

Если обновлена одна строка, резерв получен. Если ноль строк, остатка не хватило, и можно честно отвечать отказом. При двух одновременных запросах база обрабатывает обновления одной и той же строки последовательно: второй увидит уже увеличенное значение reserved, условие не выполнится, и он получит ноль затронутых строк. Остаток 1 достанется ровно одному из запросов.

Явная блокировка строки. Если логика сложнее, например нужно проверить ещё и число активных заданий, и таблицу возможностей, можно внутри транзакции выбрать строку квоты с блокировкой (SELECT ... FOR UPDATE), выполнить все проверки и затем записать результат. Пока транзакция не завершена, конкурент ждёт. Цена этого подхода — ожидание, поэтому транзакцию надо держать короткой: никаких обращений к модели и к внешним сервисам внутри неё. Блокировка нужна на миллисекунды, а не на время ответа ИИ.

Теперь та же ситуация с резервом, на тех же цифрах.

Остаток 1, два конкурентных запроса, резервирование
ШагОбработчик AОбработчик Bgranted / reserved / used
1Резерв 1: условие верно, строка обновлена1 / 1 / 0
2Резерв 1: доступно 1 − 1 − 0 = 0, условие неверно1 / 1 / 0
3Создаёт задачу в очередиОтвечает отказом: остаток исчерпан1 / 1 / 0

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

Резерв как запись, а не как число

Счётчик reserved хорош, пока всё идёт гладко. Но резерв без истории — мина замедленного действия: если обработчик упал между резервированием и постановкой задачи, число так и останется завышенным, и пользователь потеряет единицу без всякого ответа. Поэтому рядом со счётчиком нужна таблица резервов, где каждая запись — это отдельное обращение.

В ней достаточно хранить: идентификатор резерва, пользователя, период, зарезервированную сумму, статус, время создания и идентификатор задачи, если она уже создана. Статусы образуют небольшой конечный автомат: reserved (удержано), committed (списано по факту), released (возвращено без списания), expired (истекло по таймауту).

Такая таблица решает сразу несколько задач. Во-первых, у каждого изменения счётчика есть причина, а значит, расхождение можно расследовать. Во-вторых, повторный вызов безопаснее: если резерв уже перешёл в committed, второе подтверждение ничего не изменит. В-третьих, сборщик устаревших резервов может периодически находить записи в статусе reserved старше разумного срока и освобождать их. Срок надо выбирать исходя из максимального времени жизни задачи в очереди плюс времени выполнения, иначе вы освободите резерв у задания, которое ещё работает.

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

Идентификатор обращения вместо надежды на единичность

Сетевая неопределённость неизбежна. Вы отправили запрос модели, соединение оборвалось, и вы не знаете, выполнился ли он на стороне провайдера. Повторить? Тогда, возможно, заплатите дважды. Не повторять? Тогда пользователь останется без ответа. Здесь нет честного способа гарантировать «ровно одно списание», и обещать его не стоит: максимум, что можно сделать, — спроектировать систему так, чтобы последствия повторов были ограничены и объяснимы.

Практический подход: у каждого пользовательского обращения есть собственный стабильный идентификатор, и он создаётся один раз при приёме сообщения, а не при каждой попытке. Если вы просто генерируете новый идентификатор на каждом повторе, это ничего не даёт: система будет считать повтор новой операцией. Стабильность идентификатора нужна именно для того, чтобы ваша база распознала повторную обработку того же обращения и не завела второй резерв. Для этого пригодится уникальный индекс по паре «пользователь, идентификатор сообщения Telegram» или аналогичной комбинации, которая однозначно описывает входящее событие.

На стороне провайдера возможности зависят от конкретного API: поддерживает ли он ключ идемпотентности, можно ли запросить статус выполненного вызова. Эти вещи нужно проверять в документации того сервиса, которым вы пользуетесь, а не переносить на него допущения от другого. Если надёжного способа выяснить исход нет, остаётся политика, которую вы выбираете осознанно: либо в сомнительных случаях списывать резерв (защищаете себя от лишних затрат), либо возвращать (защищаете пользователя). Для небольшого бота разумно склониться ко второму: потеря одной единицы квоты обходится дешевле потери доверия.

Куда идёт резерв после ответа модели

Задача получила резерв, попала в очередь, исполнитель вызвал модель. Что происходит с зарезервированной суммой дальше? Здесь стоит разобрать конкретные исходы, потому что у каждого своя судьба.

Судьба резерва по исходам
ИсходДействие с резервомЧто сказать пользователю
Ответ получен полностьюПеревести в committed, списать фактический расходОтвет и обновлённый остаток
Ответ получен, но обрезан по лимиту длиныСписать фактический расход, пометить обрезкуОтвет с пометкой, что он неполный
Ошибка провайдера до начала генерацииОсвободить резерв целикомСообщение об ошибке; единица не потрачена
Таймаут без ясного результатаЗависит от выбранной политики; зафиксировать решение в записиЧестно сказать, что результат не получен
Пользователь отменил задачу до стартаОсвободить резервПодтверждение отмены
Пользователь отменил задачу во время выполненияСписать то, что уже израсходовано, если это известноСообщить, что часть расхода учтена

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

У этого подхода есть неприятная сторона: пользователь с небольшим остатком может получить отказ на запрос, который по факту обошёлся бы дешевле остатка, просто потому что верхняя оценка не уложилась. Это компромисс, и его лучше смягчить текстом отказа, а не скрывать (об этом в конце).

Корректировка расхода и её границы

Если ответ оказался дороже оценки, то есть вы ошиблись в верхней границе или провайдер посчитал токены иначе, возникает перерасход. Допустим ли отрицательный остаток? Можно сделать любой выбор, но его нужно сделать явно. Строгий вариант: ограничить длину ответа параметрами запроса так, чтобы оценка сверху была надёжной, а факт превышения считать ошибкой учёта. Мягкий вариант: разрешить небольшой уход в минус и вычесть его из следующего периода. Нулевой перерасход обещать не стоит: между вашим учётом и счётом провайдера всегда может быть расхождение, и разумно периодически сверять сумму израсходованного в базе с отчётом провайдера.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.

АРЕНДОВАТЬ СЕРВЕР

Что даёт бюджет шлюза и чего он не даёт

Если вы пропускаете запросы к моделям через прокси-шлюз вроде LiteLLM, соблазн велик: «бюджеты уже есть, зачем писать свои». В документации LiteLLM описаны бюджеты и лимиты скорости: индивидуальные, командные и внутригрупповые. Там же отмечено, что проверка бюджетов требует базы данных, а без неё глобальный бюджет запросы не ограничивает. Значит, прежде чем полагаться на бюджет шлюза, убедитесь, что база подключена и проверки действительно работают.

Но даже при правильной настройке бюджет шлюза решает другую задачу, чем тарифная квота бота. Бюджет шлюза отвечает на вопрос «сколько денег или токенов можно потратить через этот ключ или этого пользователя шлюза». Он защищает ваш счёт у провайдера. Квота подписки отвечает на вопросы продукта: что именно вы пообещали пользователю в этом тарифе, на какие функции, на какой период, что происходит при продлении, отмене и возврате платежа. Шлюз ничего не знает о вашем successful_payment, о таблице возможностей по планам и о политике возврата резерва.

Разумное разделение труда такое: квота бота — первая линия, которая реализует обещание пользователю и отказывает с понятным объяснением; бюджет шлюза — страховка второй линии, которая срабатывает, если ошибка в вашей логике или всплеск запросов могли бы разорить вас. Если шлюз отказал раньше, чем квота бота, это сигнал, что лимиты не согласованы, и такой отказ пользователю лучше показывать как временную недоступность, а не как исчерпание тарифа: он ведь ничего не исчерпал.

Очередь как второй ограничитель

Квота токенов не отвечает на вопрос, сколько заданий выполняется одновременно. А он важен: один пользователь, отправив двадцать запросов подряд, при ещё не исчерпанной квоте займёт всю очередь и заставит остальных ждать. Поэтому у тарифа должен быть ещё и лимит параллелизма.

Простая схема: у каждого плана есть число одновременных активных заданий, например одно на базовом и несколько на старшем (цифры — ваш выбор, а не стандарт). При постановке в очередь вы проверяете не только остаток квоты, но и число записей в статусах «в очереди» и «выполняется» для этого пользователя. Проверку нужно делать в той же короткой транзакции, что и резерв, иначе гонка вернётся тем же путём: два сообщения одновременно увидят «активных заданий ноль».

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

Продление, смена тарифа и отмена

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

Продление

При продлении создаётся новый период с новой квотой. Главный вопрос: переносится ли неиспользованный остаток? Это продуктовое решение, у него нет технически правильного ответа. Если не переносится, то остаток прошлого периода сгорает, и резервы, оставшиеся в нём, нужно закрыть. Если переносится, то чаще всего с потолком, иначе накопления превратятся в неожиданную нагрузку на вашу сторону.

Техническая тонкость: задание, поставленное в очередь в конце старого периода, может выполниться уже в новом. Поэтому запись резерва должна ссылаться на конкретный период, а списание обязано идти в тот же период, к которому относится резерв. Иначе фактический расход ляжет не туда, а старый резерв повиснет навсегда.

Смена тарифа

Повышение тарифа в середине периода обычно выглядит так: пользователь доплачивает, новый план действует сразу. Как пересчитать квоту — зависит от вашей модели: дать полный объём нового плана, добавить разницу, начислить пропорционально оставшимся дням. Для базы данных важнее другое: изменение плана и пересчёт квоты должны произойти одной транзакцией, а уже зарезервированные суммы не должны потеряться. Не пересоздавайте строку квоты с нуля, а изменяйте поле выделенного объёма, сохраняя счётчики резервов и расхода.

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

Отмена и возврат платежа

Отмена автопродления не прерывает текущий период: подписка действует до его конца. Другое дело возврат платежа. Идентификатор оплаты у вас сохранён, значит, возврат можно связать с конкретной подпиской. Дальше нужно решить, что делать с остатком и с использованным. Варианты: закрыть доступ немедленно и обнулить остаток; оставить уже израсходованное как есть, а неизрасходованное аннулировать; в особых случаях ограничиться предупреждением. Какой бы вы ни выбрали, правило должно быть записано в коде единообразно, а не вычисляться по ситуации.

Задания, которые уже выполняются

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

  • Дать закончить. Задание уже поставлено на законных основаниях, резерв удержан. Пусть оно завершится и спишет фактический расход. Это проще и не ломает пользовательский опыт.
  • Прервать. Имеет смысл, если расход крупный, а возврат платежа означает, что оплаты за него нет. Тогда исполнитель должен периодически проверять статус задачи, а резерв переходит в released или частично списывается по уже полученному.

Для большинства небольших ботов подходит первый путь с ограничением по времени жизни задания. Главное, чтобы финальное списание не создавало новую активность у пользователя без права: после закрытия подписки задание, дошедшее до конца, не должно запускать цепочку продолжений вроде «сгенерировать ещё один вариант».

Какого сервера хватит для старта

Вся описанная логика не требует тяжёлого железа сама по себе. Бот, база с квотами и резервами и воркер очереди — это обычные процессы. Основную нагрузку несёт не ваш сервер, а внешний провайдер модели, к которому вы обращаетесь по сети. Поэтому размер сервера определяется числом одновременных заданий в ожидании ответа, объёмом базы и тем, что ещё на нём живёт. Точного числа пользователей, которое выдержит та или иная конфигурация, назвать нельзя без замера на вашей нагрузке.

Практичная последовательность такая. Для начала можно взять самый компактный тариф и держать на нём бота, PostgreSQL и воркер. На VPS MAATRIX это, например, Ba с 1 vCPU, 2 ГБ RAM и 40 ГБ NVMe. Следующая ступень, Uab с 2 vCPU, 4 ГБ и 80 ГБ, даёт больше запаса, если рядом с ботом работает прокси-шлюз и вы хотите, чтобы база и воркеры не конкурировали за память. Дальше идут Heka (4 vCPU, 8 ГБ, 160 ГБ), Maat (8 vCPU, 16 ГБ, 240 ГБ) и Djed (16 vCPU, 32 ГБ, 320 ГБ). Эти цифры — описание ресурсов, а не обещание ёмкости, и переходить выше стоит по метрикам: загрузка процессора, нехватка памяти, время ожидания в очереди, размер таблиц.

Есть практическое преимущество собственного сервера для такой задачи: у вас root-доступ, и вы сами выбираете, как поставить PostgreSQL, где держать воркеры и как смотреть блокировки. Ту же диагностику через pg_locks, о которой шла речь выше, удобно выполнять непосредственно на машине с базой. Оплата в MAATRIX возможна картой РФ и через СБП, а сервер, как указано в публичном предложении, создаётся автоматически через пару минут после оплаты; локации в предложении перечислены UK, US, RU и FR. Доступность конкретной локации и образа лучше уточнить в форме заказа. Подробнее о том, как выбрать сервер и держать бота в сети круглосуточно, мы писали в статье про VPS для Telegram-бота. Если вам нужны сервер под бота и разговор о конфигурации, можно посмотреть аренду VPS MAATRIX.

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

Как проверить, что гонка действительно побеждена

Неважно, насколько аккуратна схема на бумаге: пока вы не воспроизвели ту самую одновременную отправку, уверенности нет. Проверка не требует сложной инфраструктуры. Создайте тестового пользователя с остатком 1, затем из двух параллельных потоков вызовите функцию резервирования для одного и того же пользователя. Ожидаемый результат: ровно один успех и один отказ, а в таблице резервов ровно одна запись в статусе reserved. Повторите сотню раз; одиночный прогон может пройти по случайному везению.

Второй сценарий — обрыв посередине. Остановите воркер после резервирования и до постановки задачи. Проверьте, что сборщик устаревших резервов через заданный срок освободит единицу, а пользователь увидит исходный остаток. Третий — повторная доставка платёжного события: отправьте одно и то же successful_payment дважды и убедитесь, что подписка и квота созданы один раз. Четвёртый — запрос в последние секунды периода и его выполнение уже после продления: расход должен лечь в тот период, где был резерв.

Если при нагрузочной проверке запросы начали подвисать, посмотрите на блокировки: именно для этого PostgreSQL показывает текущие блокировки через pg_locks. Долгая транзакция, которая держит строку квоты, пока вы ждёте ответа модели, — типичная причина. Лекарство известно: внутри транзакции только работа с базой, всё сетевое снаружи.

Что сказать пользователю, когда остатка нет

Вся эта архитектура в итоге сводится к одной фразе в чате: пользователь нажал кнопку, а бот ответил отказом. И качество этого ответа зависит от того, насколько честно он отражает данные backend. Расплывчатое «Лимит исчерпан» вызывает вопросы: какой лимит, когда сбросится, не сломалось ли что-то. Точное сообщение закрывает их сразу.

Разные причины отказа должны давать разные тексты, потому что у них разные последствия для пользователя.

  • Остаток исчерпан. «На вашем тарифе осталось 0 из 30 запросов в этом периоде. Новый период начнётся 14 числа.» Цифры берутся из тех же полей, по которым сработало условие резервирования.
  • Часть остатка удержана активными заданиями. «Свободных запросов нет: один запрос уже выполняется и удерживает последнюю единицу. Если он завершится ошибкой, единица вернётся.» Это как раз случай нашего пользователя с двумя сообщениями, и он заслуживает объяснения, а не загадки.
  • Функция недоступна на тарифе. «Анализ файлов входит в старший тариф. На вашем он недоступен.» Здесь остаток ни при чём, и упоминать его не надо.
  • Подписка не активна. «Срок подписки истёк, продлите её, чтобы продолжить.»
  • Слишком много одновременных заданий. «Дождитесь завершения текущего запроса.»
  • Временная недоступность модели или шлюза. «Сервис временно недоступен, ваш остаток не изменился.» Причём это утверждение должно быть правдой: перед отправкой такого сообщения резерв должен быть освобождён.

Последний пункт показывает принцип, ради которого всё и затевалось: текст отказа должен соответствовать состоянию базы. Если вы пишете «остаток не изменился», значит, резерв действительно освобождён. Если пишете «осталось 0», значит, это вычислено из granted − reserved − used, а не угадано. Поэтому формировать сообщение стоит из результата самой операции резервирования: функция возвращает не булево значение, а причину отказа с числами, и бот лишь подставляет их в шаблон.

Вернёмся к началу. Два сообщения, один остаток, пользователь у телефона. Теперь первое получает резерв и уходит в работу, а второе сразу получает короткое и точное объяснение: последняя единица занята выполняющимся запросом, и если тот не завершится, она вернётся. Никакого двойного расхода, никаких загадочных зависаний. А если первый запрос закончится ошибкой, остаток вновь станет единицей, и бот не будет делать вид, что ничего не случилось: он скажет об этом тем же языком, которым записаны цифры в базе.