В учётной системе появились два одинаковых заказа. Покупатель оплатил один раз, в Saleor заказ один, а в бухгалтерии на складе лежат две карточки с одинаковым составом и суммой. Склад собирает двойную отгрузку, остатки уходят в минус, менеджер вручную удаляет лишнее. Разбираться начинают с вопроса «почему магазин прислал событие дважды?», но он почти всегда поставлен неудачно. Магазин мог и не ошибаться: доставка событий по сети устроена так, что повтор является нормальным поведением. Ошибка в том, что принимающая сторона создаёт запись при каждом полученном запросе, как будто запрос равен факту.
Эта статья о том, как построить между Saleor и учётной системой небольшой отдельный сервис, который переживает повторы, медленные ответы и недоступность внешней системы. Мы пройдём путь события от входа до единственной записи на другой стороне и на каждом шаге посмотрим, что именно может пойти не так.
Два разных события: «я получил» и «я создал»
Первая идея, которая приходит в голову: webhook пришёл, значит, надо тут же вызвать API учётной системы, создать заказ и вернуть Saleor ответ 200. Всё в одном обработчике, меньше движущихся частей. Такая схема работает, пока всё хорошо, и ломается в самом неприятном месте: когда вызов во внешнюю систему занял слишком долго.
Полезно сразу развести два утверждения, которые в наивной схеме слиты в одно:
- Доставка подтверждена: ваш сервис получил событие, убедился в его подлинности и надёжно записал у себя. Это ответ на вопрос отправителя «дошло ли до вас?».
- Объект создан: учётная система приняла заказ, и у него появился её собственный идентификатор. Это ответ на вопрос бизнеса «заказ учтён?».
Первое происходит за миллисекунды и целиком зависит от вашего сервиса и вашей базы. Второе зависит от чужого API, его нагрузки, окон обслуживания и сети между вами. Привязывать подтверждение доставки к чужому API значит отдавать контроль над своим ответом тому, кого вы не контролируете.
Документация Saleor делит события webhook на синхронные и асинхронные. Синхронный обработчик влияет на время выполнения GraphQL-запроса: пока он не ответит, запрос покупателя или менеджера в Saleor ждёт. Асинхронный вызывается уже после завершения обработки запроса, и ожидания покупателя не удерживает. Также у webhook-запросов есть временные ограничения, поэтому долгий ответ не просто неудобен, а может обернуться потерянным подтверждением (Saleor: Webhooks overview).
Из этого следует практическое правило выбора. Если от ответа вашего сервиса зависит то, что видит покупатель в момент действия (например, итоговая цена или доступные способы доставки), нужен синхронный обработчик, и тогда каждая лишняя секунда превращается в секунду ожидания человека у экрана. Если же вы просто сообщаете учётной системе о свершившемся факте, как при создании или оплате заказа, покупателю не нужно дожидаться бухгалтерии. Здесь место асинхронному событию: магазин уже закончил свою работу, а ваш сервис занимается остальным в своём темпе. Наша тема именно такая: заказ в учётной системе нужен складу, а не человеку на странице оформления.
Что сервис делает в момент входа
Идея «быстро принять, долго обрабатывать» уместна и в документации Stripe: долгую обработку советуют отделять от быстрого успешного ответа endpoint (Stripe: Webhooks). Примем её как общий принцип приёма событий, но механизмы подписи и повторов у каждой платформы свои, поэтому детали Saleor сверяйте с его документацией, а не переносите из чужой.
Обработчик входа делает ровно четыре вещи и ничего больше:
- Читает тело запроса как есть, байт в байт.
- Проверяет подлинность отправителя.
- Записывает событие в собственное хранилище.
- Отвечает успешным статусом.
Всё остальное: разбор состава заказа, сопоставление товаров со складскими позициями, вызов учётной системы, обработка её ошибок, происходит позже, в отдельном рабочем процессе, который читает записанные события.
Подлинность: проверять надо то, что пришло, а не то, что вы распарсили
Открытый адрес приёма событий видят все, кто его угадал или нашёл в логах. Без проверки любой может отправить «заказ оплачен» и создать запись в бухгалтерии. Поэтому подлинность проверяется до всякой записи. В Saleor для этого есть поддерживаемый механизм подписи webhook; конкретные заголовки и алгоритм берите из актуальной документации той версии Saleor, которую вы используете, и не подставляйте названия по памяти или из других платформ.
Общая механика проверки подписи у разных систем одна и та же, и Stripe описывает её наглядно: подпись считается от исходного тела запроса, поэтому проверять нужно raw body, а любое изменение тела до проверки делает её некорректной (Stripe: Webhooks). Это объясняет типичную ловушку. Веб-фреймворк по умолчанию разбирает JSON, и вы получаете объект. Если потом сериализовать его обратно, порядок ключей, пробелы, экранирование символов могут отличаться от оригинала, и подпись «не сойдётся» на совершенно честном событии. Поэтому в обработчике входа порядок такой: сначала сохранить сырые байты, проверить подпись по ним, и только затем разбирать JSON. Для Saleor подробности вычисления сверяйте с его документацией, но принцип «подпись проверяется по неизменённому телу» защищает от целого класса загадочных отказов.
Если подпись не прошла, событие не записывается и не обрабатывается. Ответ с кодом ошибки клиента подойдёт: честный отправитель после исправления конфигурации ключа повторит попытку, нечестный получит отказ без подробностей. В логи стоит писать факт отказа и время, но не содержимое тела целиком: в заказе есть персональные данные покупателя.
Запись до работы: таблица состояния вашего сервиса
Сохранённое событие должно отвечать на два вопроса: «что за событие?» и «видели ли мы его раньше?». Для второго нужен стабильный идентификатор, и тут критично, откуда его взять. Нельзя придумывать его самому в момент получения (случайный UUID при каждом запросе по определению разный у повторных доставок и ничего не опознаёт). Нужен идентификатор, который берётся из данных Saleor и одинаков при повторной доставке одного и того же. Для самого заказа это идентификатор заказа из схемы Saleor: он стабилен на всём протяжении жизни заказа. Какие именно поля события и заголовки доступны в вашей версии, смотрите в документации и схеме GraphQL; важно, что ключом дедупликации служит значение, пришедшее от Saleor, а не сгенерированное вами.
Таблица состояния принадлежит вашему сервису интеграции, Saleor её не создаёт и не знает о ней. Вот пример структуры; названия колонок наши, и их можно менять под себя:
| Поле | Назначение |
|---|---|
| saleor_order_id | Идентификатор заказа из Saleor; уникальный ключ строки |
| raw_payload | Тело события как получено |
| status | received, sending, unknown, synced, failed |
| external_id | Идентификатор записи в учётной системе, когда он известен |
| attempts | Сколько раз пытались отправить |
| last_error | Краткая причина последней неудачи |
| updated_at | Время последнего изменения состояния |
Уникальное ограничение на saleor_order_id делает базу вашим вратарём. Пришёл повтор того же события: вставка строки упирается в ограничение, сервис понимает «такой заказ уже есть», ничего не создаёт и отвечает успехом. Повтор получает тот же ответ, что и первое событие, и отправителю нечего пересылать снова. Дубль, который в наивной схеме порождал вторую карточку в бухгалтерии, здесь гасится на входе, ещё до внешней системы.
Заметим тонкость: события об одном заказе бывают разных типов (создан, оплачен, отменён), и тип влияет на то, что надо сделать во внешней системе. Если вы ведёте по строке на заказ, то разные типы событий не должны затирать друг друга. Простой вариант: отдельная таблица или колонка для входящих событий с ключом из данных Saleor и типа, а состояние синхронизации заказа хранить отдельно. Эта статья держит сквозной пример на создании заказа, остальное строится так же.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации Россия, США, Франция и Великобритания. Оплата картой РФ и по СБП.
Тайм-аут: самый неприятный ответ — «неизвестно»
Теперь рабочий процесс берёт строку со статусом received, переводит её в sending и вызывает API учётной системы. Возможны три исхода, и два из них простые: система ответила успехом (сохраняем external_id, статус synced) или явно отказала (например, отклонила запрос как некорректный; статус failed и причина в last_error для человека).
Третий исход ломает наивные схемы: соединение оборвалось или время ожидания вышло. Что произошло на той стороне? Возможны оба варианта. Запрос мог вообще не дойти, и тогда заказа там нет. А мог дойти, обработаться, заказ создан, а оборвался только ответ по дороге назад. Снаружи эти две ситуации выглядят одинаково: тишина. Если в этот момент просто повторить запрос «на всякий случай», то во втором варианте и родится тот самый двойной заказ.
Поэтому для этого исхода нужен отдельный статус, например unknown, и честное понимание: результат операции неизвестен, а не отрицательный. Транзакция вашей базы данных не может атомарно включить в себя чужой вызов: вы можете надёжно записать, что собирались отправлять и сколько раз пробовали, но не можете заставить внешнюю систему откатиться вместе с вашей записью. Поэтому единственный способ выйти из неизвестности — спросить саму учётную систему.
Сверка вместо слепого повтора
Существует два рабочих способа превратить «неизвестно» в «известно», и какой из них доступен, зависит от возможностей вашей учётной системы. Что она умеет, надо смотреть в её документации; здесь описана логика, а не гарантии конкретного продукта.
Способ первый: запрос на поиск. Перед повтором сервис спрашивает учётную систему, есть ли у неё запись с внешним номером, равным идентификатору заказа Saleor. Для этого при первой отправке номер заказа Saleor надо передавать в поле, по которому можно искать (внешний номер, комментарий, пользовательский реквизит). Нашлась запись: сохраняем её external_id, переводим строку в synced, повтор не нужен. Не нашлась: можно безопасно отправить ещё раз. Условие, о котором стоит помнить: поиск должен видеть только что созданные записи без заметной задержки. Если система обновляет индекс с отставанием, сверку лучше проводить не сразу, а через некоторое время.
Способ второй: ключ идемпотентности на стороне получателя. Если API учётной системы принимает ключ повторяемости запроса и по нему возвращает тот же результат, то ключом должно быть значение, стабильное для заказа, то есть производное от идентификатора заказа Saleor. А вот новый идентификатор на каждую попытку ничего не даст: система увидит каждую попытку как новую операцию, и дубль вернётся. Стабильность ключа между повторами и есть вся идемпотентность. Но проверьте, что система действительно документирует такое поведение и как долго хранит ключи, прежде чем на него полагаться.
Если не работает ни одно из двух (поиска нет, ключей нет), честнее перевести строку в статус, требующий ручного решения, чем гадать. Человек проверит в учётной системе, есть ли заказ, и отметит результат. Для редких тайм-аутов это дешевле, чем разбор двойных отгрузок.
Недоступность учётной системы: очередь, а не авария
Учётная система не обязана быть доступной всегда: у неё бывают обновления, ночные задания, перегрузки при инвентаризации. Для интеграции это означает, что недоступность внешнего API — плановое состояние, а не катастрофа. Именно ради этого мы приняли событие и записали его до обработки: Saleor свою задачу выполнил и получил успешный ответ, а накопленные строки со статусом received просто ждут.
Рабочий процесс при ошибках соединения или ответах, говорящих о временной перегрузке, возвращает строку в очередь и увеличивает attempts. Интервалы между попытками разумно делать растущими: через минуту, через пять, через четверть часа. Так вы не добиваете систему, которая и без того поднимается. Для каждой строки должен быть предел попыток или возраста, после которого она получает статус failed и попадает в список для человека. Бесконечные повторы без внимания человека превращают очередь в кладбище.
Порядок обработки тоже имеет значение. Если у заказа есть события «создан» и «оплачен», то отправка «оплачен» раньше «создан» бессмысленна: во внешней системе ещё нечего отмечать оплаченным. Проще всего обрабатывать события одного заказа последовательно: пока заказ не получил external_id, зависимые от него события ждут. Между разными заказами порядок не нужен, и их можно обрабатывать параллельно.
Понятный статус для людей
Интеграция, у которой есть только «работает» и «упала», раздражает всех. Таблица состояния даёт то, что нужно сотрудникам, без обращения к разработчикам. Менеджер, которому позвонил покупатель, хочет знать одно: попал ли заказ в учёт. Ответ читается прямо из строки:
- received: событие принято, до учётной системы ещё не дошло; обычно это секунды или время простоя внешней системы.
- sending: попытка отправки идёт сейчас.
- unknown: запрос ушёл, но результат не подтверждён; сервис сверит с учётной системой.
- synced: заказ создан, external_id сохранён.
- failed: нужна проверка человеком, причина в last_error.
Строка, застрявшая в sending дольше разумного времени, означает, что рабочий процесс упал посреди попытки. Правильная реакция — не повторять отправку, а перевести её в unknown и пройти сверку по описанным выше правилам. Для этого достаточно периодически искать строки в sending с давним updated_at.
Какой сервер нужен такому сервису
Сервис интеграции небольшой: веб-обработчик, рабочий процесс и база с одной основной таблицей. Нагрузка определяется потоком заказов и скоростью учётной системы, а не размером каталога, и потому заранее назвать нужные ресурсы честно нельзя: это покажет замер на ваших заказах. Но сама структура подсказывает, как размещать. Приёмный endpoint должен быть доступен из интернета по HTTPS, чтобы Saleor мог к нему обратиться, а база и очередь не обязаны быть видны снаружи. Рабочему процессу нужен исходящий доступ к API учётной системы, и его проходимость из выбранного места размещения стоит проверить заранее, а не считать само собой разумеющейся.
На VPS MAATRIX (NVMe, AMD EPYC, root-доступ; в публичном предложении указаны локации UK, US, RU и FR) такой сервис можно поднять отдельным процессом рядом с магазином или на отдельной машине. Тарифы начинаются с Ba (1 vCPU, 2 ГБ RAM, 40 ГБ NVMe) и поднимаются до Djed (16 vCPU, 32 ГБ, 320 ГБ); это параметры ресурсов, а не обещание пропускной способности, поэтому начните с малого тарифа и сравните с реальными замерами. Как поднять сам магазин, рассказано в статье Как установить и настроить Saleor на VPS. Отдельный сервис интеграции удобен тем, что его можно перезапускать и обновлять, не трогая магазин: события, пока он перезагружается, не пропадут, если Saleor повторит доставку, а накопленные строки обработаются после запуска. Именно поэтому полезно хранить данные состояния на диске, а не в памяти процесса. Резервное копирование базы интеграции организуйте самостоятельно: автоматические копии во всех тарифах в публичном предложении не заявлены.
Прогон по одному заказу
Соберём всё на условном сценарии: заказ оформлен, Saleor отправляет событие о создании. Сервис получает сырое тело, проверяет подпись, находит в данных идентификатор заказа и вставляет строку received. Ответ 200 уходит сразу. Покупатель к этому моменту уже видит страницу «спасибо за заказ» и ничего не ждёт.
Рабочий процесс забирает строку, ставит sending и отправляет заказ в учётную систему, передавая идентификатор заказа Saleor как внешний номер. Связь обрывается. Строка становится unknown. Через минуту сверка ищет запись по внешнему номеру и находит: система создала заказ, просто не успела ответить. Сервис записывает external_id и ставит synced. Второго заказа нет, хотя первая попытка формально «не удалась».
Теперь та же история, но Saleor ещё и повторил доставку события, потому что не получил вовремя подтверждение по каким-то своим причинам. Второе событие приходит, вставка упирается в уникальный ключ, сервис отвечает успехом и не создаёт ничего нового. Две независимые защиты, ключ на входе и сверка на выходе, закрывают две разные причины дубля: повтор от магазина и повтор от самого сервиса.
Сколько записей на заказ
Зелёный индикатор доставки webhook говорит только о том, что запрос дошёл до вашего адреса. Он не говорит, что склад получил заказ, и тем более не говорит, что он получил его один раз. Мерой здоровья такой интеграции служит другое утверждение: каждому заказу Saleor соответствует ровно одна запись в учётной системе, а в вашей таблице у этой пары записан external_id. Проверить его можно простым отчётом: сколько строк в synced, сколько в unknown и failed дольше допустимого срока и нет ли у одного external_id двух заказов Saleor. Когда эти числа сходятся, интеграция работает, а повторы и тайм-ауты перестают быть событием, которое надо разбирать вручную.