Запрос ушёл, соединение оборвалось, ответа нет. Для чтения данных это пустяк: спросили ещё раз. Но если запрос создавал отправление или покупал этикетку, возникает неприятная неопределённость. Сервер доставки мог не получить запрос вообще. Мог получить и отклонить. А мог выполнить, списать деньги, выпустить этикетку и потерять только обратный ответ. С вашей стороны все три случая выглядят одинаково: тишина и таймаут.
Эта статья о том, как построить интеграцию так, чтобы такая тишина не превращалась в двойную покупку. Как пример модели 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) означает: в боевом режиме у заказа может быть ровно одна строка. Вторая вставка завершится ошибкой базы, а не тихо добавит дубль. Если ваша бизнес-логика допускает несколько посылок на один заказ, ключ надо расширить, например номером места, но принцип тот же: сочетание, определяющее «ту самую посылку», должно быть уникальным.
Теперь порядок действий. Он важен, потому что от него зависит, что вы будете знать после сбоя.
- В своей базе вставьте строку со state = «creating», external_id = NULL и reference, равным, например, номеру заказа. Если вставка не удалась из-за уникальности, значит попытка уже есть, и новое создание не запускайте: переходите к разбору её состояния.
- Только после успешной фиксации этой строки вызывайте внешний API.
- Получив ответ с ID, запишите его и переведите state в «created».
Почему именно так, а не «сначала вызов, потом запись»? Потому что при обратном порядке падение процесса между вызовом и записью оставляет внешний объект, о котором ваша база ничего не знает. При правильном порядке, как бы ни оборвалась работа, в базе остаётся след: строка в состоянии «creating» без внешнего ID говорит: «мы собирались, исход неизвестен». Это и есть тот самый сигнал для сверки.
Обратите внимание на ограничение, которое нельзя обойти запросом к базе: транзакция вашей базы данных не охватывает вызов чужого API. Нельзя написать «начать транзакцию, вызвать сервис, зафиксировать» и считать, что откат отменит внешнюю операцию. Откатывается ваша строка, а этикетка у перевозчика остаётся. Поэтому состояния и сверка — это не усложнение, а замена того атомарного механизма, которого между вашей базой и внешним сервисом нет.
Три исхода запроса и что с каждым делать
Любой ответ на изменяющий запрос попадает в одну из трёх корзин. Хорошая интеграция различает их явно, а не складывает все неудачи в «ошибка, повторим».
Успех
Пришёл ответ, в нём есть ID объекта. Записываем ID и mode, переводим состояние вперёд. Проверьте также, что режим в ответе совпадает с тем, в котором вы работаете: это дёшево и ловит перепутанные ключи окружений.
Явный отказ
Сервис ответил, что запрос не принят: например, адрес не распознан или не хватает данных о посылке. Ответ получен, исход известен: объект не создан (если документация вашего API так трактует данный класс ответов). Это безопасная ситуация для исправления данных и новой попытки, но сначала надо исправить причину. Слепой повтор того же запроса даст тот же отказ. Состояние переводим в «failed» с текстом причины, который увидит оператор или покупатель, а повторную попытку делаем после правки адреса или параметров. Классы ответов (какие коды означают окончательный отказ, а какие временную перегрузку) берите из документации конкретного API, а не из общих привычек: у разных сервисов они трактуются по-разному.
Неизвестный исход
Таймаут, обрыв соединения, ответ без тела, ошибка сервера посреди обработки. Вы не знаете, дошёл ли запрос и что с ним стало. Вот единственное место, где совет «просто повторить» опасен, и именно он чаще всего встречается в общих руководствах. Для запросов, которые только читают, повтор безвреден. Для изменяющих повторять автоматически можно лишь тогда, когда в документации конкретного API подтверждено, что повтор не создаст второй объект. Для Shipment в EasyPost на изученной странице такого подтверждения для reference нет, значит, действуем осторожнее: переводим строку в «unknown» и запускаем сверку.
Сверка: как выяснить, что случилось, прежде чем повторять
Сверка — это набор вопросов к внешней системе, не меняющих её состояние. Порядок такой, от самого дешёвого к самому трудоёмкому.
- Есть ли у вас ID? Если запись содержит external_id, неопределённости по созданию нет. Остаётся выяснить состояние объекта (например, куплена ли этикетка) чтением по ID. Повторять создание не нужно вовсе.
- ID нет, но есть reference. Тут нужна документация вашего API: есть ли способ найти или перечислить объекты по метке или по периоду времени. Из страницы Shipment известно лишь, что reference допустимо подставлять вместо ID в некоторых методах; готовой гарантии поиска по нему там не заявлено. Если в вашем API есть список отправлений с фильтрацией по времени создания, сузьте окно до момента сбоя и сопоставьте метки. Нашли один объект с вашим reference — привяжите его ID к заказу. Нашли два — это уже дубль, который надо разобрать вручную.
- Не нашли ничего и способа искать нет. Тогда отправьте строку на ручную проверку. Человек откроет кабинет сервиса, посмотрит список по номеру заказа и примет решение. Такой путь медленнее, но дешевле лишней этикетки.
И только если сверка убедительно показала, что объекта нет, создание можно запускать снова. При этом строка остаётся той же: вы не вставляете вторую запись, а возвращаете имеющуюся из «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», значит, ответ потерян, и сначала надо искать, а уже потом создавать. Пока эта запись уникальна и единственна, один заказ не превратится в два отправления без вашего ведома.