Тихие сбои: баг, который прошёл все тесты и убил все кнопки
Бот работал, сервис был зелёный, логи чистые — и ни одна кнопка не работала ни у кого. Разбираю на реальном коде, почему такие дефекты не ловит ни компилятор, ни тесты, ни ревью, и что делать вместо наращивания покрытия.
Коротко (TL;DR)
- Бот работал, сервис был «зелёный», логи чистые — и при этом ни одна кнопка не работала. Ни у кого. Никогда.
- Причина — цепочка проверок в неправильном порядке. Каждое поле в ней реально существует, дефолт разумный, на ревью читается корректно.
- Юнит-тесты проходили, потому что подавали синтетическое событие, в котором нужного поля просто нет. Тест защищал от случая, которого не бывает.
- Это целый класс дефектов: правильный и неправильный ответ имеют одинаковую форму, одинаковый поток управления и одинаковые логи.
- Генерация кода моделями делает такие дефекты массовыми: модель отлично пишет правдоподобный код и ничего не знает про поведение конкретной платформы.
- Лечится не количеством тестов, а четырьмя приёмами: реальный трафик вместо фикстур, дифф выхода на одинаковом входе, проверки на эффект вместо отсутствия ошибки, явный ответ на вопрос «что будет на шаге, который система не тянет».
Сцена: всё работает, ничего не работает
Я веду один и тот же продукт в двух мессенджерах — в Telegram и в MAX. Внешне платформы похожи настолько, что это усыпляет. Бот отправляет сообщение с инлайн-кнопками, человек жмёт кнопку, обработчик находит пользователя и делает своё дело. Логика на обеих платформах одна и та же, различаться должны только детали транспорта.
Мы выкатили версию для MAX. Сервис поднялся, вебхук отвечал 200, ошибок в мониторинге ноль, в логах — ровно то, что ожидалось от здорового сервиса. И при этом ни одна кнопка не работала. Не «иногда», не «у части пользователей», не «под нагрузкой». Совсем. У всех. С первой минуты.
Самое неприятное в этой истории даже не то, что баг был. Баг был бы полбеды, если бы он как-то о себе заявил. Проблема в том, что система не имела ни одного способа сообщить о нём. Ни исключения, ни предупреждения, ни аномалии в метриках. Формально всё было в порядке — и формально всё оставалось в порядке ровно столько, сколько мы не смотрели на продукт глазами пользователя.
Причина: цепочка в неправильном порядке
Разница между платформами оказалась в том, кого считать отправителем события.
В Telegram, когда человек нажимает инлайн-кнопку, в апдейте приходит именно тот, кто нажал. Читаешь пользователя из события — и дальше работаешь с ним.
В MAX нажатие кнопки приходит как событие, которое содержит сразу два объекта: callback и то самое message, к которому кнопка была прикреплена. И вот тут ловушка: message.sender — это автор сообщения. А автор сообщения с кнопками — сам бот. Человек, который нажал, лежит отдельно, в callback.user.
Код выглядел так:
sender = (
message.get("sender") # бот — на каждом нажатии
or callback.get("user") # сюда никогда не доходило
or payload.get("user")
or {}
)
Посмотрите на эту конструкцию внимательно. Она не выглядит ошибочной. Все три поля существуют в документации платформы. Есть аккуратный дефолт — пустой словарь, никакого падения по None. Порядок «сначала отправитель сообщения, потом колбэк» кажется естественным: сообщение — более общий случай, колбэк — частный.
Но событие нажатия кнопки всегда несёт в себе сообщение. Значит первая ветка срабатывала всегда. Значит sender на каждом нажатии становился ботом.
Дальше код брал идентификатор из этого объекта, шёл в базу искать пользователя, не находил его — потому что бот не зарегистрирован как пользователь — и возвращался. По штатной ветке. Без исключения. Ничего не сломалось: просто ответ на вопрос «кто нажал» оказался неверным, а неверный ответ имел ровно ту же форму, что и верный.
callback.get("user") поднимается на первое место. В этом и суть проблемы: починка тривиальная, а вот обнаружение — нет.Почему это не поймал ни один рубеж защиты
Разберём по слоям, потому что каждый слой промахнулся по своей собственной причине, и эти причины стоит понимать отдельно.
Компилятор и типы. Претензий нет и быть не могло. Типы сходятся: словарь, метод get, строка на выходе. Все ключи, к которым мы обращаемся, действительно есть в полезной нагрузке платформы. С точки зрения статического анализа перед нами абсолютно здоровый код.
Рантайм. Вебхук вернул 200. Никакой обработчик исключений не сработал, потому что исключений не было. Health-check зелёный, аптайм сто процентов. Мониторинг честно показывал, что сервис жив — он и был жив.
Логи. Логировать было нечего. Ситуация «искали пользователя, не нашли» — это законный результат поиска, а не сбой. Если писать в лог каждый ненайденный объект, лог превратится в шум за сутки. Успех и этот конкретный провал шли по одному и тому же пути в коде, а логи описывают путь, а не смысл.
Юнит-тесты. Вот здесь самое интересное. Тесты проходили — и проходили честно. Они подавали на вход синтетическое событие типа «пришло сообщение». В таком событии нет объекта callback вообще. То есть в тестовой фикстуре первая ветка цепочки была единственной доступной и, следовательно, правильной. Тест проверял ровно тот сценарий, в котором ошибки не существует.
Код-ревью. Человек на ревью видит защитную цепочку по документированным полям с разумным дефолтом. Здесь нет запаха. Информации, необходимой для обнаружения дефекта, вообще нет в коде — она находится в поведении платформы на одном конкретном типе события. Ни один читатель диффа не обязан её знать.
Ещё два таких же места в той же функции
Когда мы нашли первый случай, я перечитал функцию разбора событий целиком — и нашёл ещё два места той же природы.
Первое — членство в канале. В Telegram членство проверяется запросом: спрашиваешь у API, состоит ли пользователь в канале, получаешь ответ. В MAX это поток событий: user_added и user_removed. И на этих событиях субъект лежит уже не в callback и не в message.sender, а в payload["user"]. То есть sender приходится переопределять третий раз, отдельной веткой:
if mapped in (UpdateType.CHANNEL_SUBSCRIBED, UpdateType.CHANNEL_UNSUBSCRIBED):
member = payload.get("user") or {}
sender = member or sender
Второе — сам идентификатор. Форма объекта пользователя отличается между типами событий, поэтому собирать идентификатор приходится ещё одной цепочкой:
max_user_id = str(
sender.get("user_id") or sender.get("id") or callback.get("user_id") or ""
)
Итого: три разных ответа на вопрос «кто пользователь» внутри одной функции, на одной платформе. Каждый из них правильный в своём контексте и катастрофически неправильный в чужом. Ни один из них не защищён типами, ни один не проверяется тестом, который вы напишете, не зная об этой особенности заранее.
Третий вариант той же болезни: телефон в vCard
Ещё один случай, чуть менее драматичный, но идентичный по механике.
Когда пользователь делится номером телефона, Telegram отдаёт номер структурным полем. MAX кладёт его внутрь vCard-строки, и её надо разбирать самому:
if not phone and p.get("vcf_info"):
m = re.search(r"TEL[^:]*:\s*([+\d][\d\s()\-]*)", p["vcf_info"])
if m:
phone = m.group(1).strip()
Регулярка написана нормально. Но если платформа пришлёт вариант форматирования, который она не покрывает, phone останется None. А None — это валидное значение: оно означает «пользователь не поделился номером». Онбординг для такого человека тихо остановится, и он окажется единственным, у кого не работает регистрация, при полностью здоровом сервисе.
Обратите внимание на общий узор всех трёх случаев. Ни в одном из них система не приходит в некорректное состояние. Во всех трёх она приходит в корректное состояние, соответствующее другому сценарию. Это принципиально другой класс дефектов, чем те, на которые рассчитаны наши инструменты.
Класс дефектов: когда верный и неверный ответ неотличимы
Сформулирую общее правило, ради которого я всё это и разбираю.
Инструменты, которыми мы ловим баги, устроены одинаково: они сравнивают поведение системы с описанием известного сбоя. Тест — это описание. Бенчмарк — описание. Правило линтера — описание. Алерт в мониторинге — описание. Автоматический ревьюер — набор описаний, выученных на чужих ошибках.
У дефектов этого класса описания нет до тех пор, пока кто-нибудь на них не наступил. Их нельзя «покрыть тестами» заранее, потому что чтобы написать тест, нужно уже знать, что callback.user должен идти первым. А если вы это знаете, вы просто напишете код правильно.
Отсюда неприятный вывод: увеличение количества юнит-тестов на такой системе почти не двигает вероятность поймать этот класс. Вы наращиваете покрытие сценариев, которые уже придумали, а инциденты приходят из тех, которые не придумал никто.
Почему генерация кода делает этот класс массовым
Здесь я скажу вещь, которая расходится с типичной подачей темы.
Когда обсуждают риски кода, написанного моделью, обычно говорят про галлюцинации, про несуществующие библиотеки, про уязвимости. Это реальные проблемы, но у них есть общее свойство: они шумные. Несуществующий импорт падает на первом запуске. Синтаксическая чушь не компилируется. Плохая уязвимость находится сканером.
Настоящая проблема тише. Модель прекрасно генерирует правдоподобный код. Цепочка проверок из моего примера — ровно такой код: аккуратный, защитный, с дефолтом, идиоматичный. Именно так пишет хороший разработчик и именно так пишет хорошая модель.
Чего модель не знает — это поведения конкретной платформы на конкретном типе события. Такого знания нет ни в документации в общем виде, ни в типах, ни в примерах. Оно появляется только у того, кто уже отлаживал этот случай руками.
Дальше арифметика простая. Объём написанного кода растёт, скорость написания растёт, доля кода, который никто не читал построчно, растёт. Класс «громких» ошибок ловится инструментами всё лучше. Класс «тихих» — не ловится вообще, и его доля в общем числе инцидентов растёт просто потому, что знаменатель уменьшается.
Формулируя коротко: автоматические ревьюеры поднимают планку по тем сбоям, которые кто-то уже сумел назвать. Они не трогают те, которые никто не догадался назвать. У большинства команд, катающих ботов и агентов, почти все продовые инциденты — из второй категории.
Что делать вместо наращивания тестов
Четыре приёма, которые реально окупаются на такой системе. Я расставил их в порядке отношения пользы к затратам.
1. Реальный трафик вместо синтетических фикстур
Синтетическая фикстура — это ваше представление о том, как выглядит событие. Именно это представление и было неверным. Записывайте настоящие апдейты с боевого вебхука, обезличивайте и складывайте в набор для прогонов. Один настоящий апдейт от нажатия кнопки поймал бы этот баг мгновенно, потому что в нём есть callback.
Практически: заведите режим, в котором сервис пишет сырой payload каждого типа события по одному экземпляру. Через неделю у вас будет каталог реальных форм событий платформы. Это дешевле любого мока и честнее.
2. Прогон на одинаковом входе и дифф выхода
Запускайте систему на одном и том же наборе входов до и после изменений и сравнивайте выход целиком, а не «упало / не упало». Тихий дрейф проявляется как разница в диффе и никогда — как исключение.
Это же ловит регрессии при обновлении зависимостей и при изменениях на стороне платформы, о которых вас никто не предупредит. Мессенджеры меняют формат событий без объявления войны.
3. Проверять эффект, а не отсутствие ошибки
«Обработчик отработал без исключений» — это не критерий успеха. Критерий успеха — «состояние изменилось так, как должно было». В нашем случае правильная проверка звучит так: после события нажатия кнопки в базе должна появиться запись действия, привязанная к человеку. Такая проверка ловит баг сразу, потому что записи не появляется.
4. Явный ответ на вопрос «что на шаге, который система не тянет»
Это самое важное и самое пропускаемое. У любого автоматизированного сценария есть шаг, на котором он не справляется. Вопрос не в том, можно ли этот шаг убрать — нельзя. Вопрос в том, что происходит, когда до него доходит.
В ассистентах записи, которые я делаю для сервисного бизнеса, повторяется одна и та же картина: запросы, на которых бот спотыкается, — это непропорционально часто самые дорогие клиенты. Те, кто просит что-то чуть в сторону от стандартного меню. Команда видит процент неуспеха и пытается автоматизировать сильнее. Правильное движение противоположное: раньше распознать уход со сценария и быстрее передать человеку.
Во что это обходится в деньгах
Переведу в понятную плоскость, потому что для малого бизнеса это не абстракция.
Тихий сбой стоит дороже громкого по трём причинам.
Время обнаружения. Громкая ошибка обнаруживается за минуты — вас будит алерт. Тихая живёт до тех пор, пока кто-то не откроет продукт глазами пользователя. В моей практике типичная дистанция — от нескольких часов до нескольких дней. Всё это время продукт формально работает.
Потери не видны в метриках. Если бот падает, вы видите всплеск ошибок. Если бот тихо не отвечает, вы видите просто более низкую конверсию — и объясняете её сезоном, трафиком, ценой, чем угодно. Причина ищется не там, где находится.
Бьёт по верхушке воронки клиентов. Смотрите пункт про ассистентов записи: ломается именно нестандартный сценарий, а нестандартный сценарий — это чаще всего клиент с большим чеком.
Отсюда практический вывод для заказчика разработки: требуйте от подрядчика не «покрытие тестами», а прогон на реальном трафике и проверки на эффект. Первое звучит солиднее и стоит дешевле в реализации. Второе действительно защищает.
Чек-лист приёмки для бота или агента
Список, по которому я принимаю собственную работу. Годится и для приёмки у подрядчика.
- Собран каталог реальных событий платформы: по одному сохранённому сырому payload на каждый тип. Не моки.
- Есть прогон системы на этом каталоге, результат сравнивается диффом с предыдущим прогоном.
- Каждый сценарий проверяется утверждением про изменение состояния, а не про отсутствие исключения.
- Для каждой точки, где определяется «кто пользователь», написано, из какого поля и на каком типе события он берётся. Это документ, а не комментарий в коде.
- Определено поведение на шаге, который система не тянет: кому и как быстро передаётся управление.
- Первые полсотни настоящих взаимодействий прочитаны человеком глазами. Не выборка, не агрегаты — подряд.
- Есть ручной сценарий приёмки: живой человек нажимает каждую кнопку в проде после выката. Да, это скучно. Это единственное, что поймало бы описанный баг за тридцать секунд.
Отдельно про перенос продукта между платформами
Раз уж речь зашла о двух мессенджерах, зафиксирую вывод, который стоил мне этой отладки.
Перенос бота на вторую платформу — это не портирование, а переписывание всех предположений о том, кто такой пользователь. Внешне API похожи: там сообщения и здесь сообщения, там кнопки и здесь кнопки, там вебхук и здесь вебхук. Именно это сходство и опасно, потому что оно провоцирует копирование кода с одной платформы на другую.
Расхождения при этом лежат не в очевидных местах вроде формата запроса, а в семантике: кто считается отправителем, где лежит человек, как узнаётся членство в канале, каким образом приходит телефон. Каждое такое расхождение — кандидат в тихий сбой, потому что скопированный код будет синтаксически валидным и логически осмысленным на обеих платформах, а верным — только на одной.
Практический вывод для планирования бюджета: закладывайте на вторую платформу не «десять процентов на адаптацию», а полноценный этап с собственной отладкой на реальных событиях. Экономия здесь возвращается сбоями, которые вы не увидите.
Как искать такие места у себя, не дожидаясь инцидента
Есть быстрый приём, который не требует ни новых инструментов, ни переписывания тестов. Займёт полдня на среднем проекте.
Найдите в коде все места, где значение выбирается из нескольких кандидатов подряд: цепочки через or, конструкции вида «взять отсюда, иначе отсюда», разбор чужой полезной нагрузки с несколькими возможными полями. В типичной интеграции таких мест от десяти до сорока.
Для каждого ответьте на два вопроса письменно. Первый: при каких входных данных срабатывает каждая ветка — не в теории, а на реальных событиях из вашего каталога. Второй: что произойдёт, если сработает не та ветка — упадёт ли что-нибудь, или система просто пойдёт дальше с неверным значением.
Все места, где ответ на второй вопрос звучит как «просто пойдёт дальше», — это ваши кандидаты в тихие сбои. Их обычно немного, единицы. Именно на них имеет смысл потратить проверку на эффект и прогон на реальном трафике, а не размазывать усилия по всему проекту ровным слоем.
Отдельно проверьте границы, где вы принимаете данные от внешней системы: вебхуки, колбэки платёжных шлюзов, ответы чужих API. Внутри собственного кода вы контролируете форму данных. На границе — нет, и меняется она без предупреждения.
Частые вопросы
Разве нормальный линтер или строгая типизация не поймали бы это? Нет. С точки зрения типов код корректен: словарь, обращение по существующим ключам, строка на выходе. Проблема не в типе значения, а в том, что значение семантически относится к другому субъекту. Ни один статический анализатор не знает, что автор сообщения с кнопкой — это бот, а не человек.
Почему не помог мониторинг? Потому что мониторить было нечего. Сервис отвечал 200, ошибок не было, задержки в норме. Единственная метрика, которая показала бы проблему, — количество успешно обработанных нажатий кнопок, то есть метрика на бизнес-эффект, а не на техническое здоровье. Такие метрики почти никто не заводит заранее.
Это специфика MAX или общая проблема? Конкретный случай специфичен для MAX, но класс дефектов универсален. Любая интеграция, где вы разбираете чужую полезную нагрузку и выбираете поле из нескольких кандидатов, — это то же самое место. Платёжные шлюзы, CRM, вебхуки маркетплейсов, ЭДО: везде, где есть цепочка «взять отсюда, иначе отсюда».
Как быстро это чинится, если найдено? Первый случай — одна переставленная строка, минуты. Полный аудит функции разбора событий и добавление прогона на реальных событиях — от одного до трёх рабочих дней в зависимости от количества типов событий. Несопоставимо дешевле, чем неделя работы продукта, который формально работает.
Значит, код от нейросетей писать нельзя? Можно и нужно, я сам так работаю каждый день. Вопрос не в том, кто написал строку, а в том, чем вы проверяете результат. Для внутренних инструментов, которыми пользуются сотрудники, планка ниже: сотрудник сам скажет, что сломалось. Для того, что видит клиент, планка выше — клиент не заводит баг-репорт, он просто уходит.
Что забрать с собой
Три мысли, ради которых стоило писать этот текст.
Первая: самые дорогие ошибки не падают. Они возвращают неверный ответ в правильной форме, и все ваши инструменты сообщают, что всё хорошо.
Вторая: тесты защищают от того, что вы уже придумали. В моём случае тест был написан аккуратно и проходил честно — просто он описывал сценарий, в котором ошибки не существует. Количество таких тестов можно наращивать бесконечно без всякого эффекта на этот класс дефектов.
Третья, и она важнее двух предыдущих. Чем лучше машины справляются с задачами, которые мы умеем формулировать, тем сильнее работа разработчика сдвигается к формулированию того, чего ещё никто не сформулировал. Найти, назвать и описать сбой, о котором нет ни строчки ни в одной документации, — это наименее автоматизируемая часть работы, а не наиболее.
Если вы заказываете бота, агента или интеграцию — спрашивайте у подрядчика не про покрытие тестами, а про то, на каких событиях он прогонял систему и что именно утверждают его проверки. Ответ на этот вопрос говорит о качестве будущего продукта больше, чем любое портфолио.
Чем могу помочь
- Боты и ИИ-агенты в Telegram и MAX
- Интеграции с CRM, 1С и платёжными шлюзами
- Аудит существующего бота: поиск тихих сбоев
- Перенос продукта со второй платформы
- Приёмка работы подрядчика
Бесплатно: чек-лист «Готов ли ваш бизнес к 152-ФЗ»
12 пунктов, которые проверяют готовность за час: данные, согласия, уведомление в РКН, локализация, защита. Отметьте, что уже сделано, и увидите дыры, за которые сейчас штрафуют.
Готовы обсудить вашу задачу?
Бесплатная консультация — разберём, как внедрить это в вашем бизнесе под ключ. Без форм, пишите напрямую.
Разборы, кейсы и практика — без воды. Выходит регулярно, читать 3–5 минут.


