Вебхук, в котором нельзя работать

Каждое нажатие кнопки в телеграм-боте — это HTTP-запрос от Telegram к нам. Около трёхсот тысяч человек в месяц, у каждого в сессии десятки нажатий, и все они приходят в один POST-эндпоинт, у которого нет ни права на пятисотку, ни права подумать.
Про «подумать» — самое неочевидное. У обычного API медленный ответ означает, что подождёт один клиент. У вебхука бота медленный ответ означает, что подождут все: количество одновременных соединений задаёте вы сами, и пропускная способность всей системы — это ровно max_connections / время_ответа. Отвечаете за 200 миллисекунд при сорока соединениях — двести апдейтов в секунду. Отвечаете за две секунды — двадцать.
Дальше — что мы вынесли из вебхука, что в нём осталось и чем за это платим. Проект под NDA: пути, имена и продуктовая часть изменены, механика настоящая.
Что вообще приезжает в вебхук
Первое, что стоит сделать с вебхуком — сузить его. В setWebhook есть параметр, который почему-то почти никто не заполняет:
$bot->getApi()->setWebhook(
url: route('telegram.webhook', $bot),
allowUpdates: ['message', 'callback_query', 'pre_checkout_query', 'my_chat_member'],
dropPendingUpdates: $drop,
secretToken: $bot->secret,
maxConnections: $bot->max_connections,
);
allowUpdates — это белый список типов апдейтов. Если его не передать, приедет всё: редактирования сообщений, реакции, изменения участников чата, опросы. Для нас это означало бы примерно вдвое больше запросов, каждый из которых дошёл бы до маршрутизатора, поднял бы чат из базы и в конце был бы выброшен как неподдерживаемый.
Четыре типа в списке — это ровно то, на что бот умеет реагировать. Всё остальное Telegram нам просто не присылает, и это самый дешёвый способ уменьшить нагрузку из всех, что я знаю: одна строчка в команде, которая выполняется один раз.
maxConnections лежит в колонке у бота, а не в конфиге, и это оказалось полезным на уровне эксплуатации: при переезде на другой сервер число можно опустить, не выкатывая код.
А вот со следующей строкой вышло нехорошо. Смотрите внимательно:
dropPendingUpdates: $this->hasOption('drop'),
hasOption() в консольной команде Laravel отвечает не на вопрос «передали ли эту опцию», а на вопрос «объявлена ли такая опция вообще». Объявлена всегда. То есть каждый раз, когда мы переставляли вебхук — при смене домена, при отладке, при добавлении типа апдейта в белый список, — мы молча выбрасывали все накопленные апдейты. Все нажатия, которые Telegram придержал, пока нас не было.
Правильно так:
dropPendingUpdates: (bool) $this->option('drop'),
Обнаружилось это, когда я писал эту статью и перечитывал команду, чтобы не соврать в тексте. Отдельное удовольствие — понимать, что баг работал ровно в те моменты, когда его последствия списывались на «ну, мы же вебхук переставляли».
Три middleware до контроллера
До контроллера запрос проходит три проверки, и порядок у них не случайный.
$webhookMiddlewares = [
FilterTelegramSubnets::class, // подсети Telegram
EnsureWebhookTelegramBot::class, // секретный токен
EnsureWebhookNotProcessed::class, // дедупликация по update_id
];
Сначала отсекаем всё, что пришло не от Telegram, по списку подсетей:
protected $allowedSubnets = [
'149.154.160.0/20',
'91.108.4.0/22',
];
Проверка дешёвая, не ходит ни в базу, ни в кэш, и поэтому стоит первой. Адрес эндпоинта у бота вечный, и находят его быстро: через неделю после запуска в логах появляются первые сканеры, а дальше — фоновый шум из попыток, который не должен доходить до базы.
Вторая — секретный токен. Telegram кладёт его в заголовок каждого запроса, мы сравниваем со своим:
$secret = $request->header('X-Telegram-Bot-Api-Secret-Token');
if ($telegramBot->secret !== $secret) {
\Log::error('Webhook received with invalid secret token', [
'bot_secret' => $telegramBot->secret,
'request_secret_token' => $secret,
'bot_id' => $telegramBot->id,
]);
return response()->json(['error' => 'Access denied'], 401);
}
В этом фрагменте два греха, и оба мои.
Первый виден сразу: в лог уезжает настоящий секрет бота. Логи потом попадают в агрегатор, в тикет, в скриншот в переписке. Класть в лог нужно факт несовпадения, а не то, с чем не совпало.
Второй тоньше: !== на строках сравнивает их посимвольно и выходит на первом же различии. Для секрета, который подбирают снаружи, это подарок в виде разницы во времени ответа. Практическая эксплуатируемость такого канала через сеть — тема холиварная, но hash_equals() пишется теми же четырьмя символами и споров не вызывает.
Дедупликация: одна строчка, которая держит всё
Третья проверка — самая интересная. Telegram повторяет апдейт, если не получил ответа или получил не 2xx. Это правильное поведение сети, и оно означает, что один и тот же update_id приедет к вам дважды в двух случаях из трёх интересных: когда вы отвечали слишком долго и когда вы упали в середине обработки.
$cacheKey = sprintf('telegram_webhook:processed:%s:%s', $telegramBot->id, $updateId);
if (!cache()->add($cacheKey, true, now()->addHour())) {
\Log::info('Webhook already processed', ['update_id' => $updateId]);
return response()->noContent();
}
return $next($request);
Ключевое здесь — add, а не has плюс put. add атомарен: в Redis это SET key value NX EX 3600, одна операция, которая либо записала и вернула true, либо не записала и вернула false. Вариант «проверили, что нет, потом записали» — это две операции с окном между ними, и на нашем потоке это окно попадается регулярно: два воркера php-fpm, два одновременных повтора одного апдейта, оба видят пусто, оба обрабатывают.
Три детали, которые стоили мне времени.
Ключ включает идентификатор бота. update_id уникален в пределах бота, а не глобально, и в тот день, когда на том же коде поднялся второй бот, без этого префикса один из них начал терять апдейты.
Час жизни ключа — это не «сколько мы помним», а «сколько Telegram может повторять». Меньше — дырка, сильно больше — лишняя память на ровном месте.
И самое неприятное: всё это работает только на общем кэше. Поставьте в .env драйвер file или array — и дедупликация превратится в тыкву, потому что у каждого процесса окажется своя память. Ничего не сломается заметно: просто изредка пользователь получит два одинаковых ответа на одно нажатие. Поэтому в тестах, которые гоняют вебхук, кэш обязан быть тем же, что и в проде.
Ответ на вебхук — это тоже вызов метода
Про это знают не все, а вещь полезная: в ответ на вебхук можно не просто вернуть 200 OK, а вернуть JSON с именем метода Bot API и параметрами. Telegram выполнит его как обычный вызов.
Вот как у нас отвечает заблокированный пользователь:
return response()->json([
'method' => 'sendMessage',
'chat_id' => $chat->chat_id,
'text' => TelegramEncode::fromDb('🚫 *Account Suspended* ...'),
'parse_mode' => 'MarkdownV2',
]);
Никакого исходящего HTTP-запроса. Мы не открываем соединение к api.telegram.org, не ждём ответа, не тратим место в исходящем лимите — сообщение уезжает тем же TCP-соединением, по которому приехал апдейт. На горячем пути, где ответ должен быть мгновенным, это экономит целый сетевой круг.
Почему тогда не везде? Потому что в ответе можно вызвать ровно один метод и нельзя узнать его результат. А нам почти всегда нужен message_id отправленного сообщения: бот живёт не лентой сообщений, а одним экраном, который редактируется на месте, и без message_id редактировать нечего.
Поэтому трюк применяется ровно в одном месте — там, где диалог заканчивается и редактировать дальше нечего. Место, где он напрашивается вторым, — ответ на неизвестную команду, но туда руки пока не дошли.
Что уехало в очередь и что осталось
Вопрос, вокруг которого строится весь контроллер: что имеет право выполняться внутри вебхука.
Осталось: разбор апдейта, поиск или создание чата, проверка бана, выбор следующего экрана и одна отправка. Всё.
Уехало: генерация, любые обращения к внешним API, любая работа, длительность которой мы не контролируем. Генерация ответа занимает от трёх до шестидесяти секунд — держать ради неё открытое соединение с Telegram нельзя даже теоретически.

Граница между «внутри» и «снаружи» проходит по одному признаку: есть ли у шага верхняя граница времени, которую гарантируем мы сами. Запрос в свою базу по индексу — есть. Запрос в чужой HTTP — нет, и неважно, какой таймаут вы поставили: таймаут в тридцать секунд означает, что худший случай — это тридцать секунд.
Полос в очереди у нас шесть: отдельная под исходящие сообщения, по паре под текст и картинки, и внутри каждой пары — приоритетная для платящих. Про приоритеты и про сообщение «вы в очереди» была отдельная статья, здесь важно другое: вебхук ничего не знает про воркеры, он только кладёт задачу и отвечает.
Исключения вместо if-ов
Внутри контроллера есть кусок, который мне нравится больше всего и который одновременно чаще всего вызывает вопросы на ревью:
try {
return $strategy->handle($bot, $chat);
} catch (InsufficientMessagesException $e) {
return new InsufficientMessagesMenu($chat);
} catch (CurrentChatNotSelectedException $e) {
return new CharacterMenu($chat);
} catch (GenerationBusyException $e) {
return $this->busyMenu($chat);
}
«Исключения — не для потока управления», знаю. Но посмотрите, что это за исключения: кончились лимиты, не выбран собеседник, уже идёт генерация. Каждое из них возникает на глубине трёх-четырёх вызовов — внутри сервиса, который резервирует баланс, внутри транзакции, внутри проверки состояния. Возвращать оттуда «экран, который нужно показать» пришлось бы через все слои, и каждый слой должен был бы уметь этот экран прокидывать.
Здесь исключение — это не ошибка, а единственный нормальный способ сказать «показываем другой экран» из глубины стека. Ошибкой оно становится, только если его никто не поймает, а ловится оно ровно в одном месте — на границе обработки апдейта.
Цена честная: набор исключений — это часть контракта контроллера, и добавить новое, забыв про catch, означает пятисотку у пользователя. Спасает то, что ловля — в одной функции, и она же служит списком того, что вообще может случиться.
Один save на апдейт
В конце обработки лежит finally, из-за которого я однажды просидел вечер:
} finally {
if (isset($chat) && $chat->isDirty()) {
$chat->save();
}
}
Идея простая: за время обработки апдейта модель чата трогают все кому не лень — локаль, время последней активности, указатель на текущий экран, стек навигации. Если каждый писал бы сам, получилось бы пять UPDATE на одно нажатие. isDirty() в конце — это один UPDATE на апдейт, и на нашем потоке разница в нагрузке на базу вполне ощутимая.
Ловушка в том, что finally выполняется и при исключении. Апдейт упал в середине — а частично изменённое состояние чата всё равно сохранится. Один раз это выглядело так: пользователь провалился в экран, которого он не видел, потому что указатель успел обновиться, а отправка сообщения — нет. Чинится либо проверкой «сохраняем только при успехе», либо тем, чтобы состояние экрана вообще менялось строго после подтверждённой отправки. Мы пошли вторым путём, но не везде, и это честный долг.
Куда уходят сокеты
Отдельная история — исходящие запросы. Каждое сообщение пользователю — это HTTPS к api.telegram.org, то есть TCP-рукопожатие плюс TLS-рукопожатие плюс сам запрос. На сотнях сообщений в секунду рукопожатия начинают стоить дороже полезной работы, а порты в состоянии TIME_WAIT — заканчиваться.
Лечится переиспользованием клиента. У нас для этого фабрика, которая держит экземпляры API в статике процесса:
private static array $apiInstances = [];
public function createForBot(TelegramBot $bot): TelegramBotApi
{
if (isset(self::$apiInstances[$bot->token])) {
return self::$apiInstances[$bot->token];
}
// ...
}
Процесс воркера живёт долго, и внутри него соединение к API переиспользуется — этого достаточно, чтобы рукопожатия перестали быть заметными.
А теперь смешное. В том же классе есть настройка HTTP-клиента, и в ней вот это:
return \Http::withOptions([
'timeout' => 30,
'connect_timeout' => 15,
'heade rs' => [ // <-- опечатка в ключе
'Connection' => 'keep-alive',
'Keep-Alive' => 'timeout=30, max=1000',
],
]);
Пробел в слове headers. Массив с заголовками никуда не передаётся: незнакомый ключ опций клиент молча игнорирует. То есть заголовков, ради которых всё писалось, не было ни дня.
Самое поучительное — что мы этого не заметили, потому что проблема с сокетами действительно ушла. Ушла она от переиспользования экземпляра клиента, а не от заголовков: в HTTP/1.1 соединение и так живёт по умолчанию, и решает не заголовок, а то, что запросы идут через один и тот же дескриптор. Опечатка год прикрывалась работающим решением, которое лечило ту же боль с другой стороны.
Мораль скорее про метод, чем про код. Мы тогда сделали два изменения одним коммитом и посмотрели на график — график выправился. Какое из двух изменений сработало, выяснилось случайно и через год.
Логи стоят денег
Log::debug на каждом шаге обработки — прекрасная вещь, пока апдейтов десять в минуту. На нашем потоке каждая такая строчка — это сериализация массива, системный вызов и место на диске, помноженные на все нажатия всех пользователей.
Чем закончилось у нас:
debugв проде выключен уровнем логгера — одной переменной окружения. Строчки остались в коде, их никто не удалял: они включаются на время, когда надо что-то поймать;infoостался только там, где событие реально редкое: повтор апдейта, апдейт из группового чата, неизвестная команда;время обработки каждого апдейта считается всегда — это
microtimeв начале и в конце, и это единственное, что попадает в метрику по умолчанию.
Последнее оказалось важнее всего остального. График времени ответа вебхука — это и есть главный график бота: в него упирается пропускная способность, и любое «мы тут добавили один запрос в базу» видно на нём сразу.
Чеклист
Если вы пишете вебхук телеграм-бота, который переживёт рост:
Сузьте
allowUpdatesдо того, что бот действительно обрабатывает.Пропускная способность — это
max_connections / время_ответа. Считайте её, а не «сколько выдержит сервер».Проверяйте секретный токен, но
hash_equals(), и никогда не логируйте сам секрет.Фильтр подсетей — первым в цепочке: он не ходит в базу.
Дедупликация по
update_id— атомарной операцией (add,SET NX), а не «проверил и записал».Ключ дедупликации включает идентификатор бота. Кэш — общий для всех процессов.
hasOption()в консольной команде отвечает не на тот вопрос, который вам кажется. Проверяйтеoption().Внутри вебхука не должно быть ни одного вызова, чью длительность гарантирует не ваш код.
Ответ на вебхук умеет быть вызовом метода API — используйте там, где результат не нужен.
Одно сохранение модели на апдейт, а не пять. Но помните, что
finallyсрабатывает и на исключении.Переиспользуйте HTTP-клиент. Заголовок
keep-aliveбез переиспользования не значит ничего.Не выкатывайте два изменения одним коммитом, если собираетесь смотреть на график.
debug-логи на горячем пути выключаются уровнем, а время обработки измеряется всегда.
И вопрос, на который у меня нет уверенного ответа. Пункт 10: где у вас проходит граница между «состояние пользователя поменялось» и «пользователь об этом узнал»? Мы сохраняем состояние после отправки и в редких случаях теряем его при падении. Обратный порядок даёт пользователя, который оказался на экране, которого не видел. Третьего варианта, кроме полноценной транзакции вокруг сетевого вызова — а её не бывает, — я не знаю.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.