Разработка · 8 мин чтения

Повторная отправка вебхуков: очередь, ретраи и защита заявок

Вебхук долетел до сервера, но обработчик упал по таймауту или ответил 500 - и событие потеряно, если ни у отправителя, ни у меня на приёме нет повторных попыток. Повторная отправка вебхуков решает именно эту проблему: сервис-источник или моя очередь на стороне получателя пытается доставить событие снова, пока оно не будет обработано и подтверждено. В интеграциях с оплатой через Т‑Банк, заказами WooCommerce, статусами СДЭК, формами Tilda и апдейтами aiogram-ботов я использую одну и ту же схему очереди и ретраев, о ней и расскажу.

Почему вебхуки теряются без повторной отправки

Причины почти всегда одни и те же. Обработчик отвечает 502, пока перезапускается контейнер после деплоя. Скрипт падает по таймауту, потому что внутри синхронно дергает CRM или платёжный шлюз, а те отвечают дольше нескольких секунд. Сеть моргнула между дата-центрами. Или самый неприятный случай: обработчик вернул 200, но упал до записи в базу - отправитель считает событие доставленным, а по факту заявка нигде не сохранилась.

Многие провайдеры платежей и служб доставки сами повторяют отправку, если не получили 200 OK, с растущим интервалом на протяжении какого-то времени. Но у любого ретрая на стороне отправителя есть предел. Если мой сервер лежит дольше, чем провайдер готов ждать, событие теряется навсегда, и я узнаю об этом только когда клиент напишет, что оплатил, а заказ в базе так и не появился. Поэтому я строю собственную устойчивую очередь на приёме, а не полагаюсь только на чужие ретраи.

Как собрать очередь для повторной отправки вебхуков: 5 шагов

Схема, которую я переиспользую в большинстве проектов, укладывается в пять шагов.

  1. Принять вебхук на лёгком эндпоинте и сразу ответить 200 или 202, ничего тяжёлого внутри запроса не делать.
  2. Положить событие в очередь с уникальным ключом идемпотентности, не дожидаясь обработки.
  3. Забрать событие воркером асинхронно и выполнить бизнес-логику: запись в БД, вызов CRM, отправка уведомления.
  4. При ошибке не терять событие, а вернуть его в очередь с задержкой перед следующей попыткой.
  5. Ограничить число попыток и после исчерпания лимита переложить событие в dead-letter очередь для ручного разбора.

В качестве самой очереди подойдёт Redis со списками и отсортированными множествами для отложенных задач, RabbitMQ с механизмом TTL и dead-letter exchange из коробки, или обычная таблица в PostgreSQL со статусом и полем next_attempt_at, если инфраструктура простая и городить отдельный брокер незачем.

Пример приёма события на FastAPI с идемпотентным ключом и постановкой в очередь Redis:

from fastapi import FastAPI, Request, Header, HTTPException
import redis
import json

app = FastAPI()
r = redis.Redis(host="localhost", port=6379, db=0)

@app.post("/webhooks/orders")
async def receive_order_webhook(request: Request, x_idempotency_key: str = Header(None)):
    if not x_idempotency_key:
        raise HTTPException(status_code=400, detail="Missing X-Idempotency-Key header")

    if r.exists(f"processed:{x_idempotency_key}"):
        return {"status": "already_processed"}

    payload = await request.json()
    event = {"id": x_idempotency_key, "payload": payload, "attempts": 0}
    r.rpush("webhook_queue", json.dumps(event))
    return {"status": "queued"}

Воркер забирает событие из очереди, обрабатывает и при ошибке кладёт обратно с растущей задержкой:

import json
import time
import redis

r = redis.Redis(host="localhost", port=6379, db=0)
BACKOFF = [10, 60, 300, 1800, 7200]
PROCESSED_TTL = 3 * 24 * 60 * 60

def process(event):
    # бизнес-логика: запись заказа, вызов CRM, обновление статуса
    raise RuntimeError("временная ошибка обработки")

while True:
    due = r.zrangebyscore("webhook_retry", 0, time.time(), start=0, num=100)
    for item in due:
        if r.zrem("webhook_retry", item):
            r.rpush("webhook_queue", item)

    raw = r.blpop("webhook_queue", timeout=5)
    if not raw:
        continue
    event = json.loads(raw[1])
    try:
        process(event)
        r.set(f"processed:{event['id']}", 1, ex=PROCESSED_TTL)
    except Exception:
        attempt = event["attempts"]
        if attempt >= len(BACKOFF):
            r.rpush("webhook_dead_letter", json.dumps(event))
            continue
        event["attempts"] += 1
        delay = BACKOFF[attempt]
        r.zadd("webhook_retry", {json.dumps(event): time.time() + delay})

Бесплатный материал

🎁 Полезный скрипт в подарок

Подпишитесь на Telegram - пришлю готовый скрипт по этой теме.

Без спама. Отписка в 1 клик.

Стратегия ретраев: задержки между попытками

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

Вот интервалы, которые я обычно беру за основу в очереди на Redis:

Попытка Задержка перед следующей Что делаю дальше
1 10 секунд Повторная попытка сразу после короткого сбоя
2 1 минута Повторная попытка
3 5 минут Повторная попытка
4 30 минут Повторная попытка
5 2 часа Последняя попытка перед dead-letter

Это не универсальная формула, а стартовые значения, которые я потом подгоняю под конкретную интеграцию. Для приёма заявок с сайта, где клиент ждёт подтверждение прямо сейчас, первые попытки я делаю чаще и короче. Для фоновой синхронизации остатков товаров растянутый интервал в часы не критичен.

Идемпотентность и защита от дублирования заявок

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

Я решаю это идемпотентным ключом. У большинства вебхуков есть уникальный идентификатор события в теле запроса или в заголовке - использую именно его, а не генерирую свой на приёме. Перед постановкой в очередь проверяю, не обрабатывался ли уже этот ключ, а после успешной обработки сохраняю его отдельным ключом с TTL, например processed:<id> на несколько суток (дольше событие повторно не пришлёт ни один нормальный отправитель). Именно отдельным ключом, а не элементом множества: TTL в Redis живёт на ключе целиком, у отдельных элементов множества своего срока жизни нет, а команды, которые меняют значение ключа не заменяя его целиком, таймаут не сбрасывают. Со множеством processed_events это означало бы одну общую дату смерти на все идентификаторы сразу. На уровне базы данных дополнительно ставлю уникальный индекс на поле с идентификатором события - это последний рубеж защиты, если очередь по какой-то причине пропустила проверку.

Примеры из практики: Т‑Банк, WooCommerce, СДЭК, Tilda, aiogram и n8n

Оплата через Т‑Банк присылает уведомления о статусе платежа отдельными запросами, и если мой обработчик ответил не 200, банк присылает уведомление повторно с растущим интервалом в течение какого-то времени. Моя задача на приёме - быстро ответить и положить событие в очередь, а не проверять оплату синхронно внутри самого запроса, иначе при нагрузке легко словить таймаут и получить лишние повторные уведомления от банка.

С WooCommerce у меня обычно обратная схема: сайт на WordPress сам отправляет вебхук при смене статуса заказа во внешнюю систему (склад, 1С, телеграм-бот менеджера). Здесь очередь и ретраи я строю уже на стороне получателя, потому что WooCommerce ничего не знает о доступности моего сервиса.

СДЭК шлёт статусы по трек-номеру, и здесь важна идемпотентность вдвойне: один и тот же статус посылки может прийти повторно, а бизнес-логика (например, уведомление клиенту в мессенджер) не должна сработать дважды.

В Tilda-скриптах, которые дергают внешний API из формы или из блока с корзиной, я обычно не полагаюсь на ретраи в браузере - вместо этого форма отправляет данные на мой промежуточный эндпоинт, который кладёт заявку в очередь и дальше сам повторяет отправку в CRM или на почту по описанной выше схеме, а то, что не ушло после всех попыток, попадает в dead-letter и разбирается руками.

В aiogram-ботах вебхук от Telegram прилетает на эндпоинт бота, и если внутри обработчика происходит долгий вызов (например, генерация ответа через AI-модель), лучше сразу подтвердить получение апдейта и обработать его в фоне через очередь, а не держать соединение открытым.

Отдельно скажу про n8n: если очередь и ретраи нужны не для гайда, а под конкретный проект здесь и сейчас, я собираю такие сценарии на этой платформе: повторная попытка включается настройкой самой ноды Retry On Fail на вкладке Settings, а для разбора сбоев и паузы есть отдельные ноды Error Trigger и Wait. Часто это быстрее, чем писать свой воркер с нуля. А если нужен именно кастомный бот с продуманной обработкой ошибок, беру такие задачи под ключ - смотрите мою разработку Telegram-бота.

Мониторинг: как не пропустить недоставленные вебхуки

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

  • Метрику размера dead-letter очереди с алертом, если там появилось хотя бы одно событие.
  • Логирование каждой попытки с идентификатором события, номером попытки и текстом ошибки, чтобы можно было восстановить историю без гаданий.
  • Отдельный дашборд или просто ежедневную выборку по количеству событий, застрявших дольше первой попытки - это обычно сигнал, что внешний сервис (CRM, банк, курьерская служба) начал деградировать.
  • Ручной интерфейс или хотя бы SQL-запрос для повторной постановки события из dead-letter в очередь после того, как причина сбоя устранена.

Без этого набора очередь превращается в кладбище потерянных заявок: события формально не теряются, они просто тихо лежат в dead-letter, а никто об этом не знает, пока клиент не позвонит и не спросит, где его заказ.

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

Сколько раз нужно повторять отправку вебхука?

Я обычно ограничиваюсь 5 попытками с растущей задержкой, после чего событие уходит в dead-letter для ручного разбора. Большее число попыток редко помогает: если сервис недоступен несколько часов подряд, дело не в кратковременном сбое, а в инциденте, который нужно решать отдельно, а не заваливать ретраями.

Чем очередь для ретраев отличается от простого cron с проверкой?

Cron, который раз в минуту проверяет базу на необработанные события, работает, но плохо масштабируется и добавляет задержку между реальным сбоем и повторной попыткой. Очередь с отложенными задачами (через Redis, RabbitMQ или похожий брокер) обрабатывает события практически сразу, как только истекла задержка, и не требует лишнего опроса базы.

Как защититься от дублирования заявок при повторной отправке?

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

Нужна ли отдельная база для очереди вебхуков или хватит Redis?

Для большинства проектов среднего размера Redis со списками и отсортированными множествами закрывает задачу полностью: он быстрый, поддерживает отложенные задачи через zadd и не требует отдельной инфраструктуры. Отдельный брокер вроде RabbitMQ имеет смысл брать, когда очередей и типов событий становится много и нужны встроенные dead-letter exchange, приоритеты и более гибкая маршрутизация.

Есть задача?

Обсудим в мессенджере

Расскажите, что нужно сделать — отвечу в течение 4 часов в рабочее время. Первая консультация бесплатно.

Продолжая пользование настоящим сайтом Вы выражаете своё согласие на обработку Ваших персональных данных (файлов куки) с использованием Yandex.Metrika.
Понятно