«Да просто выключи в staging» - как шумный 404-алерт скрывал настоящий баг
Каждый день в staging прилетал одинаковый алерт про 404 от Google Play. Отключить источник шума и починить сам режим сбоя - это разные задачи. 404 - это постоянная ошибка, повторять запрос при которой бесполезно, но клиент валил её в одну кучу с 5xx и всё равно ретраил.
Главное
Если фоновая задача, поставленная в очередь с ретраями, получает от внешнего API 404, чаще всего это постоянное условие, которое повтором не исправить. Но если клиент валит 404 в одно исключение вместе с 5xx и 429, это сжигает бюджет ретраев и заполняет алерты. Решение - развести постоянные ошибки (4xx) и временные (5xx, 429, таймауты) на границе клиента, там, где код ответа виден впервые. А ещё «выключить источник шума» и «починить режим сбоя» - разные задачи: первое убирает один конкретный алерт, второе - все случаи, когда этот сбой ещё вернётся.
Содержание
Каждый день, в одно и то же время, приходил один и тот же алерт. ERROR о том, что в staging упала задача сверки подписок (reconcile). Крон срабатывал в 3:17 ночи, через три минуты прилетал алерт. Стектрейс всегда начинался с одной и той же строки: Google Play Voided Purchases API returned 404. Три попытки ретрая, все три падали на одном и том же 404, задача улетала в DLQ, а в нашем слаке появлялось ещё одно красное уведомление.
Это не было инцидентом. Ничего не сломалось. Просто было шумно. И пока я разбирался, что делать с этим шумом, снова выучил урок: «выключить» и «починить» - разные задачи.
Почему был 404
Voided Purchases API - это эндпоинт Google Play, который возвращает список возвращённых или отменённых покупок. Мы каждый день сверяем по нему тех, кто оформил возврат втихаря, но продолжает пользоваться приложением. Но 404 от этого эндпоинта означает не «данных нет». В voidedpurchases.list 404 значит, что указанное приложение (пакет) не найдено, либо у сервисного аккаунта нет доступа к этому пакету.
У сервисного аккаунта staging нет доступа в Play Console к продакшен-пакету. Да и не должно быть - staging не то окружение, через которое проходит реальный трафик возвратов из стора. Поэтому вызов этого API из staging всегда даёт 404. Сегодня, завтра и вечно, пока не изменится конфигурация. Это не временный сбой, который мигнул и прошёл, а постоянное условие, порождённое самим окружением.
Вот тут и проходит главная граница. Временный сбой - повод ретраить: если сеть моргнула или сервер на секунду ответил 5xx, через несколько секунд можно попробовать снова. А постоянное условие ретраем не лечится. Право доступа, которого нет у сервисного аккаунта staging, не появится через три секунды. Так что три попытки ретрая этого 404 были чистой тратой ресурсов, а ERROR-алерт в конце - чистым шумом.
Почему код всё же ретраил
Причина крылась в том, как клиент классифицировал ответы. listVoidedPurchases сваливал в одну кучу и пробрасывал любой ответ, кроме 200.
if (!res.ok) {
throw AppHttpException.serviceUnavailable(
`Google Play Voided Purchases API returned ${res.status}`,
);
}
serviceUnavailable - это ошибка, которую наша очередь читает как «подлежит ретраю». Поэтому неважно, был это 404, 500 или 429, - любая ошибка отсюда ретраилась три раза и заканчивалась алертом. Проблема в том, что смысл у этих трёх статусов совершенно разный. 500 и 429 означают «сейчас не получилось, но может получиться скоро», а 404 - «в этом условии не получится и дальше». В момент, когда всё сваливается в одно исключение, ошибка, которую бессмысленно повторять, всё равно попадает в конвейер ретраев.
Забавно, что правильный ответ уже лежал в том же файле. Метод getSubscriptionPurchase того же клиента давно разделял 404 отдельно.
if (res.status === 404) {
throw new GooglePlayApiError(404, "purchase not found");
}
Один метод уважал смысл 404, другой его затирал. Прецедент был прямо в коде - просто новый метод ему не следовал. Такая асимметрия обычно возникает, когда методы писались в разные дни, под разные задачи.
Как чинили: разводим на границе
Правку сделали на двух уровнях, и оба уже следовали существующему паттерну.
Сначала на границе клиента отделили 404. Теперь 404 бросается как GooglePlayApiError(404) со смыслом «не найдено», а остальные 5xx, 429 и таймауты по-прежнему уходят через serviceUnavailable. В кандидатах на ретрай остались только те ошибки, которые действительно нужно повторять.
if (res.status === 404) {
throw new GooglePlayApiError(404, "application not found");
}
if (!res.ok) {
throw AppHttpException.serviceUnavailable(/* 5xx·429·таймаут = настоящий кандидат на ретрай */);
}
Дальше решили, как этот 404 должен обрабатывать сервис сверки. В сервисе уже был поток «если нет ключа сервисного аккаунта, тихо пропускаем» (isConfigured === false). По сути, 404 - та же ситуация: API, который в этом окружении вызвать нельзя. Поэтому его отправили в ту же ветку.
try {
const voided = await client.listVoidedPurchases();
// ... продолжаем сверку
} catch (e) {
if (e instanceof GooglePlayApiError && e.status === 404) {
this.logger.warn("voided purchases 404 - доступ недоступен в этом окружении, пропускаем");
return { skippedAppNotFound: true };
}
throw e; // если это не 404, пробрасываем дальше как обычно - идёт нормальный ретрай
}
В catch перехватывается только 404, оставляется warn-лог, и возвращается статистика по пропуску. Раз ошибка не пробрасывается дальше, не будет ни ретраев, ни DLQ, ни ERROR-алертов. А вот любая ошибка, кроме 404, пробрасывается как есть, и прежняя логика ретраев работает нормально. Замолкает только шум, а настоящая проблема по-прежнему остаётся громкой.
К этому добавили тесты: бросает ли клиент GooglePlayApiError при 404, пропускает ли сверка 404, и по-прежнему ли пробрасываются ошибки, отличные от 404. Последний случай важен - если, заглушая 404, случайно проглотить и 500, реальный инцидент останется незамеченным.
«Да просто выключи в staging?»
После того как я выложил PR, мне задали хороший вопрос: а разве не проще вообще не запускать эту задачу в staging? Интуиция верная - в staging нет реального трафика возвратов, так что вызывать API незачем в принципе. Я проверил.
Отключить оказалось не так просто, как казалось. Естественный переключатель для отключения этой функции в staging - наличие или отсутствие ключа сервисного аккаунта (isConfigured), но этот ключ использовался не только этой задачей. Проверка чеков покупок в приложении, обработка вебхуков Google Realtime Developer Notifications (RTDN), процессор сверки подписок - вся верификация подписок висела на одном и том же ключе. Чтобы тестировать в staging платежи и подписки, этот ключ нужен. Убери ключ, чтобы выключить задачу, - и заодно выключишь то, что как раз и нужно тестировать.
Тогда, может, сделать отдельный тумблер только для этой задачи? Такого тумблера не было. Значит, пришлось бы вводить новый переключатель вроде GOOGLE_VOIDED_RECONCILE_ENABLED. То есть даже «просто выключить в staging» - это всё равно работа с кодом и переменными окружения. Бесплатного способа выключить не существовало с самого начала.
Выключить и починить - разные задачи
Но настоящая суть была не в сложности. Даже если бы завести тумблер оказалось легко, это не заменило бы исправления самого 404 - потому что два подхода решают разные проблемы.
- Убрать 404 из кандидатов на ретрай - это защита на фундаментальном уровне. В любом окружении - будь то staging, продакшен с сбитой конфигурацией или будущее, где кто-то забудет включить тумблер, - это не даёт бесполезному ретраю 404 разрастись в лавину алертов.
- Отключить задачу в staging - это операционная оптимизация. Разумное дополнение поверх защиты: «в этом окружении вызывать незачем, поэтому просто не вызываем».
Порядок важен. Оставить защиту от 404 и добавить тумблер сверху - нормально. А вот добавить только тумблер и убрать защиту от 404 - значит, что однажды, когда в продакшене собьётся конфигурация и вылезет 404, алерты снова взорвутся. И тогда уже не отмахнёшься словами «это же staging, не важно». Выключить источник шума - значит убрать один конкретный алерт, который виден сейчас; починить режим сбоя - значит убрать все случаи, когда этот алерт может вернуться снова.
Когда встречаешь шумный алерт, рука сама тянется к источнику: выключить эту задачу, удалить это правило алерта, убрать из этого окружения. Порыв естественный, и иногда он оправдан. Но перед тем как выключать, стоит задать себе вопрос: я сейчас глушу алерт или чиню сбой, о котором он сигналил? Первое делает тихим этот конкретный алерт, второе - не даёт этому сбою вернуться в другом обличье.
Что нужно спросить на границе
Эта история не только про Google Play API. Любой код, который вызывает внешний сервис и ставит его ошибки в очередь на ретрай, стоит перед тем же выбором. Прежде чем править политику ретраев в настройках очереди, стоит спросить на границе клиента.
Станет ли лучше от повтора этой ошибки? Для 5xx, 429 и таймаутов обычно - да: сейчас не получилось, но скоро может получиться. Для 4xx обычно - нет: 404, 403, 400 дадут тот же ответ, пока не изменится само условие. Если свалить оба типа в одно исключение, бесполезные для ретрая сбои будут сжигать бюджет ретраев и заполнять алерты. Разводить их нужно не в очереди, а на границе клиента - там, где код ответа виден первым.
И не забывайте перед тем, как чинить, пробежаться по остальной кодовой базе. В нашем случае соседний метод в том же файле уже правильно обрабатывал 404 - изобретать заново не пришлось. Ответ, который уже лежит в коде, а новый код просто ему не следует, - ситуация куда более частая, чем кажется.
Частые вопросы
Фоновая задача бесконечно ретраит из-за 404, и алерты о падении повторяются снова и снова. Что делать?
Сначала нужно понять, временный этот 404 или постоянный. Если 404 возникает из-за того, что ресурса в этом окружении в принципе нет или нет доступа к нему, повтор запроса даст тот же результат. В этом случае правильно завести отдельный тип ошибки прямо в точке, где клиент впервые видит код ответа, и там, где 404, не перебрасывать её дальше, а логировать warn и аккуратно пропускать шаг (graceful skip). Тогда не будет ни ретраев, ни DLQ, ни ERROR-алертов.
Как отличить временную ошибку от постоянной?
В общем случае 5xx, 429 и таймауты - временные, их стоит ретраить: сейчас не получилось, но через момент может получиться. А вот 4xx вроде 404, 403 или 400 дадут тот же ответ, пока не изменится сам запрос или условие, так что повторять их бессмысленно. Это разделение лучше делать не в политике ретраев очереди, а на границе клиента - там, где код ответа виден первым.
Похожие статьи
- 💻 Разработка
Скидки, которые вы не получали - почему Spot и Graviton дешевле
Счёт от AWS второй месяц подряд превышал бюджет. Пытаясь найти, что урезать, я понял: настоящий рычаг - не «сколько потреблять», а «как покупать». История о том, почему Spot и Graviton дешевле, и о статье расходов (RDS Proxy), которая выглядит как трата впустую, но выключать её нельзя - о причине за каждой цифрой в счёте.
- 💻 Разработка
Хуки остановки срабатывали дважды - когда у SIGTERM два хозяина
После каждого деплоя в Sentry падала ошибка Prisma P2028. Полез чинить порядок завершения работы и обнаружил, что enableShutdownHooks из NestJS и библиотека graceful shutdown одновременно ловят SIGTERM, из-за чего хуки завершения каждый раз выполнялись дважды.
- 💻 Разработка
MDX-блог на Next.js своими руками - как сделан этот блог
Как я собрал блог на Next.js 16 App Router, который работает на одних markdown-файлах, без CMS. Структура папок, настройка MDX и SEO на реальном коде.