Инженерия AI-агентов

Надёжные webhook для AI-агента

Как принимать события без потерь, переживать таймауты отправителя и безопасно запускать повторную обработку.

Продвинутый уровень До 8 минут Результат: приём, дедупликация и повторная обработка

Почему обычный HTTP-обработчик ненадёжен

Webhook — входящий HTTP-запрос, которым внешняя система сообщает о событии. Отправитель обычно ждёт быстрый успешный ответ. Если сеть оборвалась или ответ пришёл поздно, он может доставить то же событие повторно.

Опасны две симметричные ситуации:

  • агент выполнил действие, но отправитель не получил ответ и повторил запрос;
  • сервер вернул успешный ответ до сохранения события, затем процесс завершился и событие исчезло.

Поэтому HTTP-запрос нельзя считать единицей обработки. Его задача — проверить подлинность, сохранить неизменяемое событие и быстро подтвердить приём. Медленные операции агента выполняет отдельный worker.

Целевая схема

Отправитель
    │ POST /webhooks/provider
    ▼
Приёмник ── проверка подписи и размера
    │
    ├── INSERT события с UNIQUE(provider, event_id)
    │
    └── 202 Accepted
             │
             ▼
          Worker ── действие AI-агента
             │
             ├── done
             └── retry_at + last_error

Гарантия здесь — не «ровно один запрос», а «как минимум одна доставка плюс идемпотентная обработка». Абсолютную exactly-once семантику между независимыми системами обычно заменить нечем: её практический эквивалент строится на уникальном ключе события и идемпотентности каждого внешнего эффекта.

Шаг 1. Создайте журнал входящих событий

Ниже — пример схемы PostgreSQL. Названия полей и интервалы являются примером конфигурации, а не обязательным стандартом.

CREATE TABLE webhook_events (
    id              bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    provider        text        NOT NULL,
    event_id        text        NOT NULL,
    event_type      text        NOT NULL,
    payload         jsonb       NOT NULL,
    payload_sha256  text        NOT NULL,
    status          text        NOT NULL DEFAULT 'pending'
                                CHECK (status IN ('pending', 'processing', 'done', 'retry', 'dead')),
    attempt_count   integer     NOT NULL DEFAULT 0,
    next_attempt_at timestamptz NOT NULL DEFAULT now(),
    locked_at       timestamptz,
    last_error      text,
    received_at     timestamptz NOT NULL DEFAULT now(),
    processed_at    timestamptz,
    UNIQUE (provider, event_id)
);

CREATE INDEX webhook_events_ready_idx
    ON webhook_events (next_attempt_at, id)
    WHERE status IN ('pending', 'retry');

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

payload_sha256 помогает обнаружить нарушение контракта: одинаковый event_id не должен неожиданно сопровождаться другим телом. Сам хеш не заменяет проверку подписи.

Шаг 2. Сначала сохраните, затем отвечайте

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

// Псевдокод: адаптируйте названия заголовков под контракт поставщика.
async function receiveWebhook(request) {
  const rawBody = await readBodyWithLimit(request, 256 * 1024);
  const signature = request.headers.get("x-webhook-signature");

  if (!verifyProviderSignature(rawBody, signature)) {
    return response(401);
  }

  const event = parseAndValidateJson(rawBody);
  const hash = sha256Hex(rawBody);

  const result = await db.query(`
    INSERT INTO webhook_events
      (provider, event_id, event_type, payload, payload_sha256)
    VALUES ($1, $2, $3, $4::jsonb, $5)
    ON CONFLICT (provider, event_id) DO NOTHING
    RETURNING id
  `, ["provider-name", event.id, event.type, rawBody, hash]);

  if (result.inserted) {
    return response(202);
  }

  const stored = await findByProviderAndEventId("provider-name", event.id);

  if (stored.payload_sha256 !== hash) {
    recordSecurityAlertWithoutPayload(event.id);
    return response(409);
  }

  return response(200);
}

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

Шаг 3. Забирайте задачи конкурентно и без гонок

Несколько worker-процессов могут безопасно выбирать разные события через FOR UPDATE SKIP LOCKED. Транзакция выбора должна быть короткой: сетевой вызов AI-модели нельзя держать внутри неё.

BEGIN;

WITH picked AS (
    SELECT id
    FROM webhook_events
    WHERE status IN ('pending', 'retry')
      AND next_attempt_at <= now()
    ORDER BY next_attempt_at, id
    FOR UPDATE SKIP LOCKED
    LIMIT 10
)
UPDATE webhook_events AS e
SET status = 'processing',
    locked_at = now(),
    attempt_count = attempt_count + 1
FROM picked
WHERE e.id = picked.id
RETURNING e.*;

COMMIT;

После фиксации worker обрабатывает возвращённые строки. На успехе он обновляет только событие, которое всё ещё принадлежит текущей попытке:

UPDATE webhook_events
SET status = 'done',
    processed_at = now(),
    locked_at = NULL,
    last_error = NULL
WHERE id = $1
  AND status = 'processing'
  AND locked_at = $2;

Для строгой защиты от позднего worker лучше хранить отдельный случайный lease_token и сверять его при завершении. Это не секрет, а идентификатор аренды задачи.

Шаг 4. Сделайте действия агента идемпотентными

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

Передавайте устойчивый ключ идемпотентности во все внешние API, которые его поддерживают:

idempotency_key = "provider-name:" + event_id + ":send-reply:v1"

Если целевая система не поддерживает такой ключ, заведите локальную таблицу эффектов:

CREATE TABLE agent_effects (
    effect_key  text PRIMARY KEY,
    event_id    bigint      NOT NULL REFERENCES webhook_events(id),
    status      text        NOT NULL CHECK (status IN ('started', 'done')),
    result_ref  text,
    created_at  timestamptz NOT NULL DEFAULT now(),
    updated_at  timestamptz NOT NULL DEFAULT now()
);

Локальная запись не может атомарно зафиксировать операцию в чужой системе. Поэтому при неопределённом результате сначала ищите созданный объект по устойчивому внешнему ключу, а не сразу повторяйте создание.

Шаг 5. Настройте повторные попытки и карантин

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

// Псевдокод задержки с ограничением и случайным разбросом.
baseSeconds = 2
capSeconds = 900
delay = random(0, min(capSeconds, baseSeconds * 2 ** attemptCount))
nextAttemptAt = now() + delay
UPDATE webhook_events
SET status = CASE WHEN attempt_count >= 8 THEN 'dead' ELSE 'retry' END,
    next_attempt_at = now() + ($2 * interval '1 second'),
    locked_at = NULL,
    last_error = left($3, 1000)
WHERE id = $1
  AND status = 'processing'
  AND locked_at = $4;

Число попыток, предел задержки и максимальная длина ошибки выше — пример. Выбирайте их по допустимой задержке и поведению зависимостей. Статус dead — карантин, а не удаление.

Зависшие задачи возвращает отдельная операция. Порог должен превышать максимальное ожидаемое время обработки:

UPDATE webhook_events
SET status = 'retry',
    next_attempt_at = now(),
    locked_at = NULL,
    last_error = 'processing lease expired'
WHERE status = 'processing'
  AND locked_at < now() - interval '15 minutes';

Безопасная ручная повторная обработка

Не меняйте payload при повторе. Верните выбранное событие из карантина в очередь и сохраните причину операции в отдельном аудите, если он предусмотрен системой.

BEGIN;

UPDATE webhook_events
SET status = 'retry',
    next_attempt_at = now(),
    locked_at = NULL,
    last_error = NULL
WHERE id = :event_id
  AND status = 'dead';

COMMIT;

:event_id — параметр подготовленного запроса, а не строка для подстановки. Перед выполнением проверьте идентификатор и убедитесь, что ошибка исправлена. Массовый replay запускайте небольшими партиями: он способен перегрузить AI API и downstream-системы.

Проверка результата

Проверку можно провести локально на тестовом событии без настоящих клиентов и секретов. Значения ниже — искусственный пример.

  1. Отправьте один и тот же тестовый запрос дважды с одинаковым event_id.
  2. Убедитесь, что в таблице появилась одна строка.
  3. Остановите worker после получения задачи, но до фиксации успеха.
  4. Запустите возврат просроченной аренды и снова включите worker.
  5. Сымитируйте временную ошибку зависимости и проверьте переход processing → retry → processing → done.
  6. Сымитируйте постоянную ошибку и проверьте переход в dead после настроенного числа попыток.
SELECT provider, event_id, count(*)
FROM webhook_events
GROUP BY provider, event_id
HAVING count(*) > 1;

SELECT status, count(*)
FROM webhook_events
GROUP BY status
ORDER BY status;

Первый запрос должен вернуть ноль строк. Во втором распределение зависит от проведённого сценария. Дополнительно проверьте, что повторный HTTP-запрос не создаёт второй внешний эффект с тем же ключом идемпотентности.

Что наблюдать в эксплуатации

  • число принятых и дублированных событий по поставщику;
  • возраст самого старого pending или retry события;
  • длительность от received_at до processed_at;
  • число повторов, просроченных аренд и событий в dead;
  • долю конфликтов, где один event_id пришёл с разными хешами;
  • ошибки downstream-систем по классифицированным причинам.

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

Типовые ошибки

Запуск агента прямо в HTTP-обработчике
Ответ задерживается, отправитель повторяет запрос, а длительная операция становится уязвимой к обрыву процесса.
Ответ 200 до фиксации INSERT
При падении процесса подтверждённое событие теряется без возможности восстановления.
Дедупликация в памяти или кеше с TTL
Состояние исчезает при рестарте или вытеснении; слишком короткий TTL пропускает поздний повтор.
Использование времени получения как ключа
Повтор приходит в другое время и выглядит новым событием.
Повтор всех ошибок
Невалидное событие бесконечно занимает worker и создаёт нагрузку.
Длительная транзакция вокруг вызова модели
Блокировки удерживаются во время непредсказуемой сетевой операции.
Логирование полного payload
В журнал могут попасть персональные данные, сообщения пользователей и служебные поля.
Слепой replay из dead-letter очереди
Если причина не устранена, повтор лишь воспроизводит ошибку и может дублировать внешние эффекты.

Ограничения

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

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

Наконец, дедупликация не определяет бизнес-семантику порядка. Если события одной сущности должны применяться последовательно, добавьте номер версии или sequence от поставщика и сериализуйте обработку по ключу сущности.

Итоговый контрольный список

  • подпись проверяется по исходному телу;
  • событие фиксируется до успешного HTTP-ответа;
  • (provider, event_id) защищён уникальным ограничением;
  • HTTP-приём отделён от работы AI-агента;
  • worker использует короткую аренду и защищён от позднего завершения;
  • временные ошибки повторяются с backoff и jitter;
  • постоянные ошибки переходят в карантин;
  • каждый внешний эффект имеет устойчивый ключ;
  • ручной replay параметризован, ограничен и наблюдаем.

Другие практические материалы собраны в разделе гайдов, а определения терминов — в глоссарии Agent Lab Journal.