Запрос ушёл, соединение оборвалось, ответа нет. Для чтения данных это пустяк: спросили ещё раз. Но если запрос создавал отправление или покупал этикетку, возникает неприятная неопределённость. Сервер доставки мог не получить запрос вообще. Мог получить и отклонить. А мог выполнить, списать деньги, выпустить этикетку и потерять только обратный ответ. С вашей стороны все три случая выглядят одинаково: тишина и таймаут.

Эта статья о том, как построить интеграцию так, чтобы такая тишина не превращалась в двойную покупку. Как пример модели API взят EasyPost: он хорошо показывает, где проходят границы между расчётом тарифа, созданием объекта и платной операцией. Это именно пример устройства, а не рекомендация сервиса: доступность конкретных перевозчиков в вашей стране нужно проверять отдельно, а российские службы доставки устроены по-своему, и их правила надо читать в их собственной документации.

Три действия, которые легко принять за одно

В интерфейсе магазина это одна кнопка: «Оформить доставку». Внутри скрываются разные по цене ошибки действия. Если смотреть на модель Shipment в EasyPost, документация разделяет их: объект Shipment объединяет адреса и посылку, его создание позволяет получить тарифы, а покупка этикетки — отдельная операция. Из этого следует практический вывод: стоимость ошибки на каждом шаге разная.

Три этапа и цена ошибки на каждом
ЭтапЧто происходитЧто будет при случайном повторе
Запрос тарифовАдреса и параметры посылки уходят в API, возвращаются варианты услуг и ценыЛишний объект на стороне сервиса и путаница в данных, но не списание за этикетку
Создание отправленияУ внешней системы появляется объект с собственным IDДва объекта для одного заказа: непонятно, к какому из них относится ваш заказ
Покупка этикеткиВыбирается тариф, выпускается этикетка, меняется баланс или счётПотенциально двойная оплата и две этикетки на одну посылку

Заметьте, что формулировка «потенциально» стоит не зря. Что именно произойдёт при повторной покупке по тому же объекту, зависит от конкретного API, и угадывать это в продакшене нельзя. Поэтому дальше мы строим систему, которой это знание не нужно: она сначала выясняет состояние, а потом уже решает, действовать ли.

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

Заказ магазина и чужой идентификатор

Центральная связь всей интеграции — одна строка: «заказ № 4821 соответствует внешнему отправлению с таким-то ID». Пока вы её не записали, ваш магазин не знает, существует ли отправление. Как только записали, дальнейшие действия можно выполнять по ID, а не «по новой».

В описании объекта Shipment в EasyPost перечислены: ID, режим mode (test или production) и необязательный reference. Эти поля нужно понимать каждое в своём смысле.

  • ID присваивает внешняя система. Вы получаете его только из ответа, и именно потеря ответа делает ситуацию опасной: объект есть, ID вы не знаете.
  • mode показывает, тестовый это объект или боевой. В базе его нужно хранить рядом с ID. Тестовый и боевой идентификаторы живут в разных мирах, и если случайно записать тестовый ID в боевой заказ, ошибка вскроется в самый неподходящий момент. Простое правило: поле режима обязательное, а в боевом окружении запись с test отвергается кодом.
  • reference — необязательная ваша метка, и по документации её можно использовать вместо ID в других методах. Это удобный крючок: туда естественно положить номер заказа магазина, чтобы человек, открыв отправление в кабинете сервиса, сразу видел, чьё оно.

Здесь легко ошибиться. На странице Shipment поле reference не объявлено гарантией дедупликации или идемпотентности создания. То есть из того, что вы передали reference с номером заказа, не следует, что второй запрос с тем же reference вернёт прежний объект, а не создаст новый. Возможно, у конкретного сервиса есть отдельный механизм защиты от повторов, но он должен быть описан в документации именно как механизм, и полагаться на него без подтверждения нельзя. Reference — это метка для сопоставления и поиска, а не замок на двери.

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

Таблица, которая не даёт создать второе отправление молча

Начнём с того, что хранить. Нужна отдельная таблица попыток или отправлений, где на заказ приходится не больше одной «живой» записи. Здесь пригодятся обычные ограничения PostgreSQL. По документации, PRIMARY KEY даёт уникальность и запрет NULL, а UNIQUE может охватывать несколько столбцов. Там же отмечено, что пустая строка — не то же самое, что NULL. Для нас это важная мелочь: если внешний ID ещё неизвестен, записывайте NULL, а не пустую строку, иначе вы создадите видимость заполненного значения. Дополнительно можно поставить проверку, что непустое значение действительно непусто.

CREATE TABLE shipments (
  id            bigserial PRIMARY KEY,
  order_id      bigint NOT NULL,
  mode          text   NOT NULL,
  state         text   NOT NULL,
  external_id   text,
  reference     text   NOT NULL,
  created_at    timestamptz NOT NULL DEFAULT now(),
  updated_at    timestamptz NOT NULL DEFAULT now(),
  UNIQUE (order_id, mode),
  CHECK (external_id IS NULL OR external_id <> '')
);

Ограничение UNIQUE по паре (order_id, mode) означает: в боевом режиме у заказа может быть ровно одна строка. Вторая вставка завершится ошибкой базы, а не тихо добавит дубль. Если ваша бизнес-логика допускает несколько посылок на один заказ, ключ надо расширить, например номером места, но принцип тот же: сочетание, определяющее «ту самую посылку», должно быть уникальным.

Теперь порядок действий. Он важен, потому что от него зависит, что вы будете знать после сбоя.

  1. В своей базе вставьте строку со state = «creating», external_id = NULL и reference, равным, например, номеру заказа. Если вставка не удалась из-за уникальности, значит попытка уже есть, и новое создание не запускайте: переходите к разбору её состояния.
  2. Только после успешной фиксации этой строки вызывайте внешний API.
  3. Получив ответ с ID, запишите его и переведите state в «created».

Почему именно так, а не «сначала вызов, потом запись»? Потому что при обратном порядке падение процесса между вызовом и записью оставляет внешний объект, о котором ваша база ничего не знает. При правильном порядке, как бы ни оборвалась работа, в базе остаётся след: строка в состоянии «creating» без внешнего ID говорит: «мы собирались, исход неизвестен». Это и есть тот самый сигнал для сверки.

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

Три исхода запроса и что с каждым делать

Любой ответ на изменяющий запрос попадает в одну из трёх корзин. Хорошая интеграция различает их явно, а не складывает все неудачи в «ошибка, повторим».

Успех

Пришёл ответ, в нём есть ID объекта. Записываем ID и mode, переводим состояние вперёд. Проверьте также, что режим в ответе совпадает с тем, в котором вы работаете: это дёшево и ловит перепутанные ключи окружений.

Явный отказ

Сервис ответил, что запрос не принят: например, адрес не распознан или не хватает данных о посылке. Ответ получен, исход известен: объект не создан (если документация вашего API так трактует данный класс ответов). Это безопасная ситуация для исправления данных и новой попытки, но сначала надо исправить причину. Слепой повтор того же запроса даст тот же отказ. Состояние переводим в «failed» с текстом причины, который увидит оператор или покупатель, а повторную попытку делаем после правки адреса или параметров. Классы ответов (какие коды означают окончательный отказ, а какие временную перегрузку) берите из документации конкретного API, а не из общих привычек: у разных сервисов они трактуются по-разному.

Неизвестный исход

Таймаут, обрыв соединения, ответ без тела, ошибка сервера посреди обработки. Вы не знаете, дошёл ли запрос и что с ним стало. Вот единственное место, где совет «просто повторить» опасен, и именно он чаще всего встречается в общих руководствах. Для запросов, которые только читают, повтор безвреден. Для изменяющих повторять автоматически можно лишь тогда, когда в документации конкретного API подтверждено, что повтор не создаст второй объект. Для Shipment в EasyPost на изученной странице такого подтверждения для reference нет, значит, действуем осторожнее: переводим строку в «unknown» и запускаем сверку.

Сверка: как выяснить, что случилось, прежде чем повторять

Сверка — это набор вопросов к внешней системе, не меняющих её состояние. Порядок такой, от самого дешёвого к самому трудоёмкому.

  1. Есть ли у вас ID? Если запись содержит external_id, неопределённости по созданию нет. Остаётся выяснить состояние объекта (например, куплена ли этикетка) чтением по ID. Повторять создание не нужно вовсе.
  2. ID нет, но есть reference. Тут нужна документация вашего API: есть ли способ найти или перечислить объекты по метке или по периоду времени. Из страницы Shipment известно лишь, что reference допустимо подставлять вместо ID в некоторых методах; готовой гарантии поиска по нему там не заявлено. Если в вашем API есть список отправлений с фильтрацией по времени создания, сузьте окно до момента сбоя и сопоставьте метки. Нашли один объект с вашим reference — привяжите его ID к заказу. Нашли два — это уже дубль, который надо разобрать вручную.
  3. Не нашли ничего и способа искать нет. Тогда отправьте строку на ручную проверку. Человек откроет кабинет сервиса, посмотрит список по номеру заказа и примет решение. Такой путь медленнее, но дешевле лишней этикетки.

И только если сверка убедительно показала, что объекта нет, создание можно запускать снова. При этом строка остаётся той же: вы не вставляете вторую запись, а возвращаете имеющуюся из «unknown» в «creating». Уникальность по заказу сохраняется, а история попыток фиксируется отдельным журналом, если он вам нужен.

Часто спрашивают: а если на каждую попытку генерировать новый уникальный идентификатор, не решит ли это проблему? Нет. Сам по себе новый идентификатор попытки идемпотентности не создаёт: он лишь метка. Идемпотентность — это поведение сервера, который узнаёт повтор и возвращает прежний результат. Если сервер так не умеет или вы не подтвердили это документацией, то новая метка при каждом повторе только помогает убедиться в дублировании постфактум, а не предотвращает его.

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

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

АРЕНДОВАТЬ СЕРВЕР

Сколько стоит неопределённость: небольшой расчёт

Чтобы масштаб был виден, возьмём условные цифры: магазин оформляет 2000 отправлений в день, и у 0,5% запросов на создание или покупку исход неизвестен из-за таймаутов. Это 10 случаев в день. Пусть этикетка стоит условные 5 единиц.

Если на все десять отвечать слепым повтором, и в половине случаев первый запрос на самом деле был выполнен, то за день набегает пять лишних этикеток, то есть 25 условных единиц, а за месяц около 750. Но деньги — только часть. Каждый дубль — это ещё и две этикетки на одну посылку, из которых курьеру нужна одна, возврат средств через поддержку перевозчика, и риск того, что наклеят не ту, а статусы будут приходить по двум трекинг-номерам.

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

Покупка этикетки: отдельная дверь с отдельным замком

Всё сказанное про создание справедливо и для покупки, но там ставки выше. Поэтому добавьте в таблицу состояний шаги вокруг покупки: «buying», «purchased» и признак, что результат неизвестен. Правило прежнее: сначала зафиксировать намерение в своей базе, затем вызывать API, затем записать результат.

Перед любой повторной попыткой покупки выполняйте чтение: запросите объект по сохранённому ID и посмотрите, не появилась ли у него этикетка или выбранный тариф. Если появилась, ваша задача не купить заново, а просто сохранить данные этикетки и перевести заказ вперёд. Чтение вместо записи — самая надёжная страховка, потому что оно не может стоить денег.

Если же по чтению видно, что покупки не было, повторить её можно. Но и тут остаётся узкое окно: между чтением и новой покупкой кто-то другой (второй воркер, оператор в кабинете) мог её совершить. Это решается тем же способом, что и создание: покупку в вашей системе выполняет один исполнитель на заказ, а переход в состояние «buying» делается атомарным обновлением строки с условием по текущему состоянию. Если обновление затронуло ноль строк, значит, покупкой уже занимается другой процесс, и вам остаётся ждать.

UPDATE shipments
SET state = 'buying', updated_at = now()
WHERE order_id = 4821 AND mode = 'production' AND state = 'created';

Если результат — одна затронутая строка, блокировка ваша. Ноль — не ваша. Это обычная условная запись, никакой магии, но именно она не даёт двум воркерам одновременно нажать «купить».

Тест и боевой режим: две изолированные вселенные

Раз у объекта есть mode, весь код нужно писать с мыслью, что тестовые и боевые данные не должны пересекаться. Практические следствия таковы.

  • Режим хранится в строке и входит в уникальный ключ. Тестовое отправление заказа не мешает боевому и не считается «уже созданным».
  • Ключи доступа для тестового и боевого режимов лежат в разных переменных окружения, а не выбираются флагом внутри одного и того же кода. Так случайно отправить боевую покупку из тестового контура труднее.
  • Тестовый заказ с тестовым ID никогда не отображается оператору как рабочая посылка. Добавьте метку в интерфейсе или фильтр по режиму.
  • Сверка работает в режиме строки. Искать боевой объект в тестовом пространстве бессмысленно, а ошибка выглядит как «объекта нет» и подталкивает к повторному созданию.

Последний пункт самый коварный: перепутанный режим превращает неизвестный исход в ложное «ничего не найдено». Поэтому режим из ответа API и режим из вашей строки сравнивайте всегда.

Статус перевозки: отдельная история после создания

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

Источников статуса может быть два: уведомления, которые внешняя система присылает на ваш адрес, и опрос по расписанию. Какие из них предлагает ваш сервис доставки, нужно смотреть в его документации; на изученной странице Shipment об этом ничего нет. Разумно предусмотреть оба способа: уведомления дают быстроту, а опрос страхует от пропущенных. Принцип обработки в обоих случаях одинаков.

  • Привязка по ID. Входящее событие содержит идентификатор отправления. По нему вы находите свою строку. Если такого ID нет в вашей базе, событие не отбрасывайте, а откладывайте: возможно, вы ещё не успели записать ответ на создание.
  • Повтор безвреден. Одно и то же событие может прийти дважды. Обработчик должен приводить заказ в состояние, определяемое статусом, а не прибавлять к нему. Установить «доставлено» второй раз — ничего не меняет, а вот отправить покупателю письмо второй раз уже плохо. Поэтому вместе со сменой статуса храните отметку, что письмо ушло, и проверяйте её.
  • Порядок не гарантирован. Событие «в пути» может прийти после «доставлено». Сравнивайте время события в данных перевозчика, а не время получения вами, и не откатывайте заказ назад по более старой новости.
  • Неизвестный статус. Перевозчик может прислать значение, которого вы не знали. Сохраняйте исходную строку статуса как есть, а во внутреннее состояние переводите по таблице соответствий; если соответствия нет, сохраняйте как «прочее» и сообщайте разработчику, но не роняйте обработку.

Полезно хранить две вещи отдельно: сырой статус перевозчика и внутреннее состояние заказа в магазине. Первый нужен для разбора споров («мы видели вот такое значение в такое время»), второй для логики сайта. Если смешать их в одно поле, придётся при каждом изменении набора статусов переписывать историю.

Где всё это работает: воркер, очередь и сетевое окружение

Всё описанное требует постоянно работающего процесса: исполнителя, который берёт строки в состоянии «creating» и «unknown», периодически опрашивает статусы и принимает входящие уведомления. Выполнять это в обработчике запроса посетителя на сайте плохо: таймаут браузера и таймаут API не совпадают, а перезапуск веб-процесса посреди покупки только приумножит неизвестные исходы.

Поэтому фоновый воркер и очередь обычно выносят на отдельный сервер. На VPS MAATRIX для этого есть всё нужное: NVMe-диски, процессоры AMD EPYC и root-доступ, чтобы поставить своего воркера, менеджер процессов и базу. В публичном каталоге есть и образ n8n с установкой для тех, кто предпочитает собирать такие сценарии из блоков, но точное наличие образа и операционной системы стоит проверять в форме заказа. Из тарифной линейки для небольшого магазина с воркером и базой вполне может хватить начальных конфигураций вроде Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) или Uab (2 vCPU, 4 ГБ, 80 ГБ), но это ориентир для старта, а не обещание ёмкости: нагрузку нужно измерять на своём потоке заказов. Расположение выбирайте из перечисленных в предложении: UK, US, RU и FR, исходя из того, где находятся ваши покупатели и сервис доставки, и не рассчитывайте, что внешний API одинаково доступен из любой локации. Это нужно проверить.

Один сервер, разумеется, сам по себе не избавляет от дублей: защиту создают состояния в базе и сверка, а не размещение. Резервные копии базы заказов стоит настроить самостоятельно: из публичного предложения не следует, что они включены автоматически на всех тарифах. Потеря таблицы с внешними ID равносильна возвращению в ситуацию «объект есть, а мы о нём не знаем», только в массовом масштабе.

Если у вашего сервиса доставки или другой внешней системы есть требования к исходящему адресу сервера, то вопрос стабильного канала и адреса подробнее разобран в статье про интеграцию с API ФНС. Конкретные условия вашего сервиса надо смотреть в его собственных правилах.

Как выглядит жизнь одного заказа

Соберём всё в одну историю. Покупатель оформил заказ № 4821. Магазин вставил строку отправления: заказ 4821, режим production, состояние «creating», внешний ID пуст, reference равен номеру заказа. Воркер вызвал API на создание. Соединение оборвалось. Воркер не знает исхода и переводит строку в «unknown».

Сверка видит пустой external_id и ищет объекты с меткой 4821 за интересующий отрезок времени. Находит один, записывает его ID, mode совпадает, состояние становится «created». Повторного создания не было. Затем склад подтверждает посылку, магазин переводит состояние в «buying» условным обновлением, перед этим прочитав объект по ID и убедившись, что этикетки ещё нет. Покупка проходит, ответ записан, состояние «purchased». Дальше статусы перевозчика приходят уведомлениями и опросом, а обработчик спокойно переживает дубли и перестановки.

Если поиск ничего не нашёл, а способа искать нет, строка остаётся в «unknown» и показывается оператору с подсказкой, где смотреть. Это не поломка, а честная запись того, что исход не подтверждён.

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

Вся конструкция сводится к одной связи: в вашей базе строка «заказ 4821, режим production» с единственным внешним ID отправления. Именно эту связь можно проверить после любого сетевого сбоя: откройте строку и сравните её с тем, что видит кабинет сервиса доставки. Если ID записан, отправление существует и привязано к заказу. Если ID пуст, а состояние «unknown», значит, ответ потерян, и сначала надо искать, а уже потом создавать. Пока эта запись уникальна и единственна, один заказ не превратится в два отправления без вашего ведома.