Как AI-агент отменил все подписки в Stripe: анатомия одного undefined
Человек проснулся, а его MRR упал до 38 долларов: воркер, написанный AI, ночью отменил все активные подписки всех клиентов. Разбираем цепочку из трёх ошибок, где undefined протёк до UPDATE и снёс WHERE, и почему зелёные тесты ничего не заметили. В конце чек-лист, чтобы прогнать по своему проекту.
Утро, когда MRR стал 38 долларов
Представь. Ты ложишься спать владельцем работающего SaaS. Просыпаешься, открываешь дашборд, а там 38 долларов месячной выручки. Не потому что клиенты ушли. Не потому что Stripe лёг. Потому что твой собственный фоновый воркер ночью, за 7 секунд, отменил все активные подписки всех клиентов. Все. Разом.
Именно это произошло с автором Vibecademy. Код воркера писал AI-агент. И самое вкусное в этой истории даже не сам баг, а то, почему его никто не поймал до продакшена.

Автор твита сделал из этого вывод в духе «этой модели нельзя доверять, а вот той можно». Вывод удобный, но, на мой взгляд, мимо. Дело не в конкретной модели. Цепочка, которая тут сработала, поджидает любого, кто пишет код с агентом: и на GPT, и на Claude, и руками в пятницу вечером. Давай разберём её по косточкам, а в конце прогоним чек-лист по твоему собственному проекту.
Сцена: что вообще делал этот воркер
Исходники проекта автор не публиковал. Но модель написала на себя подробный post-mortem. Так в индустрии называют разбор инцидента постфактум: что случилось, из-за чего, и что изменить, чтобы не повторилось. В этом post-mortem (он на скриншоте ниже) модель сама перечислила всю цепочку: допущение «claimNext() вернёт задачу или null», мок, повторивший это допущение, пропущенный интеграционный тест и undefined, дотёкший до запроса по подпискам. Код в этом разборе я реконструировал по этому описанию: имена и детали условные, цепочка ошибок настоящая.
Ничего экзотического. Асинхронный воркер разгребает очередь биллинговых задач: каждая задача говорит «отмени подписку юзера такого-то». Задачи лежат в Postgres, воркер забирает их по одной:
while (true) {
const job = await claimNext(); // взять следующую задачу
if (job === null) break; // очередь пуста, стоп
await cancelStripeSubscription(job.userId); // отменить подписку ЭТОГО юзера
}Логика на вид железная: есть задача, берём из неё userId, отменяем подписку одного человека. Очередь кончилась, выходим.
А теперь смотри, как падают три костяшки домино.
Костяшка 1. Тип соврал
Функция claimNext() захватывает задачу атомарно, через типовой паттерн UPDATE ... RETURNING:
async function claimNext(): Promise<Job | null> {
const result = await db.query(`
UPDATE jobs
SET status = 'processing'
WHERE id = (SELECT id FROM jobs WHERE status = 'pending' LIMIT 1)
RETURNING user_id
`);
return result.rows[0]; // ← вот здесь бомба
}Модель задекларировала возвращаемый тип Promise<Job | null> и была уверена: нет задач, значит вернётся null.
Но когда UPDATE не заматчил ни одной строки, драйвер возвращает не null. Он возвращает пустой массив строк. А обращение к нулевому элементу пустого массива в JavaScript даёт undefined:
const result = { rows: [] };
result.rows[0]; // undefined, а НЕ nullTypeScript это проглотил молча.
Тип в TypeScript это обещание на этапе компиляции, а не проверка в рантайме. Postgres про твои типы не знает и ничего тебе не обещал. Функция, задекларированная как «вернёт Job или null», спокойно вернула undefined, и компилятор не моргнул.
Костяшка 2. Проверка мимо
Возвращаемся в воркер. Очередь опустела, claimNext() вернул undefined:
const job = await claimNext(); // undefined
if (job === null) break; // undefined === null → false. НЕ выходим!
await cancelStripeSubscription(job.userId); // едем дальше с undefinedПроверка была написана строго: три знака равенства, сравнение именно с null. А undefined === null в JavaScript это false. Выход из цикла не сработал, и код поехал дальше с job.userId, равным undefined.
Одна строчка отделяла бизнес от катастрофы. Нестрогое сравнение job == null ловит и null, и undefined. Строгое поймало только то, что автор себе вообразил.
Костяшка 3. undefined съел WHERE
userId = undefined дотёк до запроса отмены. И вот финал:
// хотели:
UPDATE subscriptions SET status = 'cancelled' WHERE user_id = 'user_123'
// получили: undefined схлопнул условие, и запрос стал таким
UPDATE subscriptions SET status = 'cancelled'
// ↑ ни одного WHERE. Апдейт всей таблицы.Многие билдеры запросов тихо выкидывают условия со значением undefined: мол, фильтр не задан, значит фильтра нет. В обычном поиске это удобно. В запросе на отмену подписок это значит «отмени всем».
Один undefined, родившийся из пустой очереди, прошёл через липовый тип, мимо кривой проверки, и снёс фильтр по юзеру в необратимой операции с деньгами. Все костяшки легли.
Почему тесты молчали: самая поучительная часть
Тесты у этого кода были. Зелёные. Судя по post-mortem, устроены они были примерно так:
// мок, который написала та же модель, что и код
const claimNextMock = jest.fn()
.mockResolvedValueOnce({ userId: 'user_123' }) // есть задача
.mockResolvedValueOnce(null); // «пустая очередь = null» ← фантазия
test('stops when queue empty', async () => {
await runWorker();
expect(claimNextMock).toHaveBeenCalledTimes(2);
});
// ✅ зелёныйВидишь подвох? Мок возвращает null на пустой очереди. Потому что модель ДУМАЛА, что придёт null. Тест проверял не поведение Postgres, а представление модели о поведении Postgres. Реальный драйвер вернул бы undefined, и тест бы упал. Но реального драйвера в тесте не было.
Когда один автор пишет и код, и моки к нему, тесты валидируют его допущения, а не реальность. Ошибочное допущение попадает в код и в мок одновременно, и они идеально подтверждают друг друга. Зелёные тесты в такой связке дают ноль гарантий. Это верно и для человека, но для AI-агента верно вдвойне: он генерит код и моки за одну сессию, из одного и того же понимания мира.
А интеграционный тест против настоящей БД, который вскрыл бы всё за секунду, был помечен skip. И модель записала это в «незначительный caveat». Для необратимой операции с деньгами пропущенный интеграционный тест это не caveat. Это release blocker.
Мета-урок: рефлексия не заменяет гейт
Дальше случилось то, из-за чего твит и завирусился. Модель попросили сделать post-mortem, и она выкатила беспощадный разбор самой себя: «that was reckless», «catastrophic failure of judgment on my part». С точным списком провалов, вплоть до главного вопроса, который она не задала себе перед деплоем: «может ли этот воркер подействовать не на того клиента, или на всех сразу?»

И вот здесь настоящий парадокс, куда интереснее, чем «модель плохая». Модель блестяще диагностировала провал постфактум, но не смогла предотвратить его в моменте. Знаний хватало: в разборе она сама перечислила всё, что надо было сделать. Не хватило другого.
Недетерминированный генератор не спасается собственной рефлексией. Сколько бы модель ни умела «подумать ещё раз», это та же вероятностная машина, которая ошиблась в первый раз. Её ловит только тупой детерминированный гейт снаружи: рантайм-assert, интеграционный тест против реальной зависимости, линтер, человек на ревью. Гейт не должен быть умным. Он должен быть тупым и срабатывать всегда.
Поэтому вывод «модель X нельзя доверять, модель Y можно» мне и не нравится. Любая модель когда-нибудь ошибётся, на то она и вероятностная. Вопрос не в том, какая модель писала код. Вопрос в том, что стояло между её ошибкой и твоим продакшеном. У автора твита не стояло ничего.
Чек-лист: прогони по своему проекту
Я прогнал этот разбор по биллингу своего проекта, честно нашёл один провал (об этом ниже). Теперь твоя очередь. Четыре секции, по мотивам всех трёх костяшек и тестов.
1. Границы «БД → рантайм»
- Все функции, обещающие
X | null, реально возвращаютnull, а неundefined, пустой объект или пустой массив? Особенно обёртки надRETURNING,findOneи raw-запросами. - Проверки пустоты ловят оба значения:
if (job == null)вместо=== null. Или, если ты в Python-мире, тебе повезло: там «ничего» одно,None. - Тип-декларации подтверждены тестом против реального драйвера, а не приняты на веру?
2. Destructive boundaries: всё, что списывает, удаляет, отменяет
Перед каждой необратимой операцией должен стоять рантайм-инвариант на входные данные:
function assertValidUserId(id: unknown): asserts id is string {
if (typeof id !== 'string' || id.length === 0) {
throw new Error(`FATAL: destructive op with userId=${id}`);
}
}
// перед КАЖДОЙ необратимой операцией:
assertValidUserId(job?.userId);
await cancelStripeSubscription(job.userId);Да, это скучный код. Он и должен быть скучным: его работа не быть умным, а взрываться при мусоре на входе. Взорвавшийся воркер чинится за час. Отменённые подписки всех клиентов не чинятся вообще.
Дальше по списку:
- Ни одного
UPDATE/DELETEбезWHERE. И ни одногоWHERE, который может схлопнуться отundefined. - Проверь свой билдер запросов: что он делает с
undefined-условием? Многие выкидывают его молча. - На критичных операциях поставь blast radius guard: лимит на число затронутых строк. Отмена больше N подписок за один проход это стоп и алерт, а не «ну, работаем».
3. Тесты
- Для всего, что трогает деньги и удаление, есть интеграционные тесты против настоящей БД, а не только моки.
- Ни один критичный тест не помечен
skipв мейне. Пропущенный тест на необратимой операции это release blocker, а не caveat. - Моки написаны на основе проверенного поведения зависимости? Если код и моки писал один автор за одну сессию, считай, что моки не доказывают ничего.
- Есть тесты на пустые и граничные кейсы: пустая очередь, пустой результат, отсутствующий юзер.
4. AI-специфичное
- Код, сгенерированный агентом на destructive boundaries, прошёл человеческое ревью? Не «пролистал дифф», а прочитал с вопросом «на кого это может подействовать в худшем случае?»
- AI-написанные тесты не приняты как доказательство корректности AI-написанного кода?
- Есть хотя бы один детерминированный гейт, не зависящий от качества генерации: assert, линтер, хук, лимит строк.
Как я прогнал это по своему биллингу
Обещал честность. У меня на сайте рекуррентная подписка через ЮKassa: FastAPI, SQLAlchemy, воркер автосписаний. Прогнал чек-лист по своему коду, и вот что вышло.
Первые две костяшки в Python-стеке не воспроизводятся физически: «ничего» там ровно одно, None, а scalar_one_or_none() из SQLAlchemy по контракту возвращает объект или None, без сюрпризов с пустыми массивами. Третья костяшка тоже не встаёт: сырых UPDATE в коде нет вообще, все изменения идут через мутацию загруженного ORM-объекта, и SQLAlchemy генерирует апдейт строго по первичному ключу одной строки. Радиус поражения любого вызова равен одной строке по построению. Плюс воркер обрабатывает каждую подписку в отдельной транзакции с блокировкой, так что «одним запросом отменить всё» в этой архитектуре просто нечем.
А вот секция тестов у меня провалена с треском: интеграционных тестов против настоящего Postgres на биллинге нет. Совсем. Пока платежи выключены фичефлагом, это терпимо. Но боевые ключи я теперь не включу, пока не появится тот самый тупой детерминированный гейт: тесты на идемпотентность вебхука, на полный цикл неудачных списаний, на гонку вебхука с воркером.
Вот, собственно, и вся мораль. Чек-лист работает, только если по нему честно находишь провалы у себя, а не киваешь «у меня-то всё нормально».
Суть в трёх строках
claimNext() вернул undefined вместо null // тип соврал
if (job === null) не поймал undefined // проверка мимо
undefined снёс WHERE в UPDATE // отменилось всё
// моки врали так же, как ошибался автор → тесты молчалиИ финальная мысль, которую я бы унёс с собой. Модель пишет код как талантливый, быстрый, но непредсказуемый разработчик: сегодня блестяще, завтра уверенно ошибётся, и заранее не угадаешь, какой день сегодня. Уравновешивать её должна не другая умная сущность, а скучные штуки, которые не умеют ошибаться, потому что не умеют думать: assert на входе в опасную операцию, тест против настоящей базы, лимит на число затронутых строк. Они срабатывают одинаково в любой день недели.
Творить пусть будет умное. Проверять должно механическое.
Такие разборы выходят регулярно
Короткие мысли и находки я публикую в телеграм-канале, а глубокие разборы, курсы и сообщество, где растут инженеры, живут здесь, в Lab.
Заведи аккаунт в ITUZOV LAB и сохраняй материалы и прогресс чтения.
Зарегистрироваться