MAATRIX / Блог / Сторонний API сменил формат даты, и заказы перестали создаваться

Сторонний API сменил формат даты, и заказы перестали создаваться

MAATRIX

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

Что сломалось

Интернет-магазин на связке Node.js (checkout-сервис) + PostgreSQL + очередь заданий на Redis. При оформлении заказа фронтенд дёргает /api/orders, бэкенд валидирует корзину и параллельно ходит в API службы доставки, чтобы получить доступные даты и получить estimated_delivery для конкретного адреса — это поле обязательно для создания заказа, потому что от него зависит расчёт стоимости и слот склада.

Около 02:40 по московскому времени в саппорт начали приходить тикеты «не могу оформить заказ, жму кнопку — ничего не происходит». Первые несколько обращений списали на разовые сбои у пользователей — обычное дело для ночи. К 04:00 счётчик успешно созданных заказов в Grafana (панель orders_created_total, инкремент из бэкенда) показывал провал примерно на 70% относительно среднего за последние недели по этому часу. Автотест на главной странице (обычный HTTP-чек GET /) был зелёным всё это время — он проверял, что сайт отдаёт 200, а не то, что через него можно что-то купить.

Важная деталь: заказ не падал с ошибкой сервера. Запрос долетал, бэкенд возвращал 422 Unprocessable Entity с телом {"error":"invalid delivery_date format"}, фронтенд показывал общий тост «Не удалось оформить заказ, попробуйте позже» — без деталей. Пользователь просто уходил.

Что видели в логах и метриках

Первым делом подняли логи checkout-service за последний час:

$ grep "invalid delivery_date" /var/log/checkout-service/app.log | tail -20
2026-08-27T02:41:03Z ERROR OrderValidation: field=delivery_date value="2026-08-28T09:00:00+03:00" reason=regex_mismatch
2026-08-27T02:41:11Z ERROR OrderValidation: field=delivery_date value="2026-08-28T14:30:00+03:00" reason=regex_mismatch
2026-08-27T02:41:19Z ERROR OrderValidation: field=delivery_date value="2026-08-28T09:00:00+03:00" reason=regex_mismatch

Ошибка одна и та же: regex_mismatch на поле delivery_date. При этом ошибка была не у всех заказов подряд — часть заказов проходила нормально. Метрика http_requests_total{route="/api/orders",status="422"} в Prometheus подскочила с фонового значения (единицы в час — обычные ошибки валидации от пользователей) до нескольких сотен в час, ровно начиная с 02:37.

nginx access log на фронтовом сервере ничего аномального не показывал: коды ответа 422 — это не 5xx, стандартный алерт на error rate (настроенный на долю 5xx) не сработал вообще, потому что технически сервис отвечал корректно на некорректный ввод — просто «некорректный ввод» на самом деле был корректным ответом партнёра в новом формате.

Sentry (у нас поднят self-hosted, инструкция есть в статье про настройку мониторинга ошибок через Sentry) поймал исключение ValidationError: delivery_date does not match pattern с трассировкой до строки:

const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
if (!DATE_RE.test(order.delivery_date)) {
  throw new ValidationError('delivery_date does not match pattern');
}

Регулярка ждала простую дату 2026-08-28, а получала 2026-08-28T09:00:00+03:00 — полноценный ISO 8601 timestamp с временем и таймзоной. Дальше начали проверять, откуда вообще берётся это значение.

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

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

Арендовать сервер

Первая гипотеза: наш деплой — отбросили за 15 минут

Первое подозрение всегда падает на «что мы сами выкатили». Смотрим git log и историю деплоев:

$ git log --since="2026-08-26 20:00" --until="2026-08-27 03:00" --oneline
(пусто)

Последний деплой checkout-сервиса был днём раньше, за 10 часов до инцидента, и не трогал ни валидацию заказов, ни интеграцию с доставкой. CI/CD пайплайн (GitHub Actions → деплой на прод через Ansible) за ночь ничего не запускал — расписания ночных релизов у нас нет намеренно. Гипотезу закрыли: причина не в нашем коде, который менялся.

Вторая и третья гипотезы: база данных и кеш CDN

Следующая по вероятности версия — деградация инфраструктуры: пул соединений к PostgreSQL исчерпан, воркер асинхронной очереди завис, или фронтенд отдаёт закешированную версию страницы оформления заказа со старой формой.

Проверили pg_stat_activity — активных соединений в пределах нормы (35 из лимита 100), long-running запросов нет, pg_stat_bgwriter без аномалий. Очередь на Redis (BullMQ) — глубина очереди order-processing не растёт, воркеры разбирают задания штатно, никто не завис. Гипотезу о базе и очереди отбросили: до записи в базу заказ просто не долетал, он падал раньше — на этапе валидации до всякого обращения к БД.

Проверили кеш: страница оформления заказа не кешируется на CDN вообще (staging-заголовок Cache-Control: no-store стоит намеренно для checkout-роутов), так что версия статики ни при чём. Второй и третий след тоже закрыли, потратив на обе версии суммарно около 40 минут — не зря, потому что без этой проверки было бы легко полдня чинить несуществующую проблему с базой вместо реальной причины.

Настоящая причина: партнёр сменил формат даты в ответе

Разбор дошёл до самого запроса к API доставки. Курьерская служба (назовём её партнёром — конкретное название тут не имеет значения) отдаёт список доступных слотов методом GET /v2/delivery-slots. Сохранённый в логах сырой ответ партнёра за 23 августа и за 27 августа отличался:

// было (до 27 августа, весь месяц работало так)
{
  "slots": [
    { "date": "2026-08-28", "warehouse": "MSK-3", "price": 0 }
  ]
}

// стало (с ночи 27 августа)
{
  "slots": [
    { "date": "2026-08-28T09:00:00+03:00", "warehouse": "MSK-3", "price": 0 }
  ]
}

Партнёр поменял формат поля date с короткой календарной даты на полноценный ISO 8601 timestamp с временем и офсетом. Судя по ответу их саппорта на следующий день, это было плановое обновление их API до версии, которая унифицирует все датовые поля под RFC 3339 — у них это прошло как «улучшение консистентности», без mail-рассылки клиентам и без записи в публичном changelog, который вообще не обновлялся с весны.

Наш код брал date из ответа партнёра и напрямую прокидывал его в delivery_date заказа, полагаясь на то, что формат всегда будет одинаковым:

// checkout-service/src/delivery.js — как было
async function pickDeliverySlot(address) {
  const res = await partnerApi.get('/v2/delivery-slots', { params: { address } });
  const slot = res.data.slots[0];
  return slot.date; // без нормализации — «партнёр же всегда отдаёт YYYY-MM-DD»
}

Часть заказов при этом проходила — потому что не все запросы на оформление шли по свежесозданному слоту: часть значений delivery_date бралась из уже выбранного пользователем на предыдущем шаге слота, закешированного на фронтенде в старом формате до смены API, и там дата была ещё в старом виде. Отсюда и «падает не всё подряд» — картина сначала выглядела случайной, а на деле зависела от того, когда конкретно пользователь дошёл до шага выбора даты доставки: до или после 02:37.

Что изменили после разбора

Сразу после диагностики (около 04:20) сделали быстрый фикс прямо в проде — заменили жёсткую регулярку на нормализацию через явный парсинг даты с отбрасыванием времени и таймзоны:

const { parseISO, isValid, format } = require('date-fns');

function normalizeDeliveryDate(raw) {
  // партнёр может прислать и "2026-08-28", и "2026-08-28T09:00:00+03:00" —
  // приводим к единому виду вместо того, чтобы ожидать конкретный формат
  const parsed = parseISO(raw);
  if (!isValid(parsed)) {
    throw new ValidationError(`unparseable delivery_date: ${raw}`);
  }
  return format(parsed, 'yyyy-MM-dd');
}

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

Во-первых, контрактный тест на ответ партнёрского API, который гоняется по расписанию раз в час отдельным cron-заданием и не зависит от живого трафика:

# /opt/checkout-service/scripts/check-partner-contract.sh
#!/bin/bash
RESPONSE=$(curl -s "https://partner-api.example.com/v2/delivery-slots?address=test")
DATE_FIELD=$(echo "$RESPONSE" | jq -r '.slots[0].date')

if ! echo "$DATE_FIELD" | grep -qE '^\d{4}-\d{2}-\d{2}$'; then
  echo "ALERT: partner date format changed, got: $DATE_FIELD"
  curl -s -X POST "$ALERT_WEBHOOK" -d "{\"text\":\"Партнёрский API изменил формат даты: $DATE_FIELD\"}"
  exit 1
fi

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

Во-вторых, завели отдельную метрику и алерт на долю 422 по /api/orders, а не только на 5xx. Раньше 422 считался «нормальной» ошибкой валидации пользовательского ввода и в алертинг сознательно не попадал — после инцидента разделили причины 422 на теговое поле reason и завели алерт именно на всплеск reason=regex_mismatch или reason=unparseable, потому что рост таких ошибок почти всегда значит проблему на стороне интеграции, а не пользователя.

В-третьих, вынесли всю нормализацию входящих данных от сторонних API в отдельный adapter-слой с явным логированием сырого ответа при ошибке парсинга — раньше сырой JSON от партнёра просто нигде не сохранялся, и первые полчаса разбора ушли на то, чтобы вручную включить дебаг-логирование запроса и подождать, пока прилетит ещё один такой ответ. Заодно эту же логику вынесли и на другие интеграции, где раньше даты, суммы в разных валютах и статусы заказов парсились похожим образом «на доверии» к формату партнёра. Тем, кто настраивает часовые пояса и форматы времени на сервере с нуля, полезно заглянуть в статью про настройку часовых поясов на сервере — там разбирается похожий класс проблем, только на уровне ОС, а не интеграции.

Отдельно завели staging-окружение, которое ходит в тот же партнёрский API, что и прод (у части команд staging ходит в песочницу партнёра с урезанным контрактом, а у нас песочницы у партнёра не было вовсе) — теперь при подозрении на смену контракта можно быстро свериться на отдельном сервере, не трогая боевой трафик. Под такие вспомогательные окружения — cron-задания с контрактными тестами, отдельный Sentry, staging с копией конфигурации — разумно держать небольшой отдельный сервер, а не подселять их на боевой инстанс: так сбой мониторинга не тянет за собой прод, и наоборот.

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

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

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Почему стандартный алерт на 5xx не поймал проблему?

Потому что сервис отвечал технически корректно — 422 на невалидные данные это ожидаемое поведение API, а не ошибка сервера. Алерты на error rate обычно считают только 5xx, и без отдельного алерта на долю 4xx по конкретным причинам такие инциденты остаются невидимыми, пока не начнут жаловаться пользователи.

Как вообще заметить смену формата в ответе стороннего API до того, как она сломает прод?

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

Стоит ли писать свой парсер дат «на все случаи» или использовать библиотеку?

Для продакшена лучше библиотеку с явной обработкой невалидных значений (date-fns, dayjs, luxon в Node.js; dateutil в Python) — они покрывают больше форматов и таймзон, чем самописная регулярка, и делают ошибку парсинга явной, а не тихим Invalid Date.

Нужно ли уведомлять партнёра о поломке или чинить только у себя?

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

Как отличить смену формата от временной недоступности партнёрского API?

По коду ответа и по валидности JSON. Недоступность обычно даёт таймаут, 5xx или пустое тело — это ловится штатными ретраями и алертами на ошибки соединения. Смена формата — это 200 OK с полностью валидным JSON, но другой структурой данных внутри; она не ловится сетевым мониторингом вообще, только валидацией содержимого.

Обсудить статью, задать вопрос или начать новую тему

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

Перейти в сообщество →