Skip to content

About

PHP-библиотека: обработчик вебхука выполняется ровно один раз, сколько бы раз провайдер его ни прислал. Захват через UNIQUE-индекс, повтор отвечает сохранённым ответом. Без зависимостей, MySQL и SQLite.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Latest commit

 

History

1 Commit

Folders and files

Repository files navigation

🔁 webhook-idempotency

Обработчик вебхука выполняется ровно один раз — сколько бы раз провайдер его ни прислал

PHP License Tests

Зависимости Хранилище Проверок


💥 Что ломается без этого

Провайдер повторит доставку, если не получил ответ вовремя. Моргнула сеть, перезапустился сервер, обработчик отработал 31 секунду вместо 30 — событие придёт ещё раз. И ещё раз.

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

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


⚡ Как это выглядит в коде

use Ecomdev\WebhookIdempotency\IdempotencyGuard;
use Ecomdev\WebhookIdempotency\RequestKey;
use Ecomdev\WebhookIdempotency\Store\PdoStore;

$guard = new IdempotencyGuard(new PdoStore($pdo));

$outcome = $guard->run(
    new RequestKey('payme', $payload['params']['id']),
    function () use ($pdo, $payload): string {
        // Всё, что меняет данные, живёт внутри замыкания.
        markOrderPaid($pdo, $payload['params']['account']['order_id']);

        return json_encode(['result' => ['state' => 2]]);
    }
);

http_response_code(200);
echo $outcome->response;   // при повторе — тот же ответ, обработчик не запускался

Полный пример эндпоинта с проверкой подписи и транзакцией — examples/payment-webhook.php.


🔄 Жизненный цикл квитанции

%%{init: {'theme':'base','themeVariables':{'fontFamily':'ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif','fontSize':'15px','textColor':'#1e1b4b','lineColor':'#4338ca','primaryColor':'#e0e7ff','primaryTextColor':'#1e1b4b','primaryBorderColor':'#4338ca','secondaryColor':'#ede9fe','tertiaryColor':'#f5f3ff','mainBkg':'#c7d2fe','nodeBorder':'#4338ca','nodeTextColor':'#1e1b4b','edgeLabelBackground':'#e0e7ff','attributeBackgroundColorOdd':'#eef2ff','attributeBackgroundColorEven':'#e0e7ff','noteBkgColor':'#fef3c7','noteTextColor':'#1a1a1a','noteBorderColor':'#b45309','clusterBkg':'#f5f3ff','clusterBorder':'#4338ca','labelBoxBkgColor':'#e0e7ff','labelBoxBorderColor':'#4338ca','labelTextColor':'#1e1b4b','actorBkg':'#c7d2fe','actorBorder':'#4338ca','actorTextColor':'#1e1b4b','actorLineColor':'#4338ca','signalColor':'#4338ca','signalTextColor':'#1e1b4b','sequenceNumberColor':'#ffffff','activationBkgColor':'#ddd6fe','activationBorderColor':'#4338ca','transitionColor':'#4338ca','transitionLabelColor':'#1e1b4b','stateBkg':'#c7d2fe','stateLabelColor':'#1e1b4b','altBackground':'#f5f3ff','compositeBackground':'#f5f3ff','compositeBorder':'#4338ca','compositeTitleBackground':'#e0e7ff','specialStateColor':'#4338ca','innerEndBackground':'#4338ca'}}}%%
stateDiagram-v2
    [*] --> processing: INSERT прошёл, доставка захвачена
    processing --> completed: обработчик вернул результат
    processing --> failed: обработчик бросил исключение
    failed --> processing: следующая доставка пробует снова
    processing --> processing: захват протух, воркер умер
    completed --> [*]: повтор получает сохранённый ответ

    note right of completed
        Из completed выхода назад нет.
        Опоздавший вебхук не откатит
        уже подтверждённый платёж.
    end note
Loading

Состояние движется только вперёд. Переход completed → processing невозможен ни при каких входящих данных — это правило проще всего нарушить тем, что обработчик просто присваивает пришедший статус.


🧩 Что происходит при повторной доставке

%%{init: {'theme':'base','themeVariables':{'fontFamily':'ui-sans-serif,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif','fontSize':'15px','textColor':'#1e1b4b','lineColor':'#4338ca','primaryColor':'#e0e7ff','primaryTextColor':'#1e1b4b','primaryBorderColor':'#4338ca','secondaryColor':'#ede9fe','tertiaryColor':'#f5f3ff','mainBkg':'#c7d2fe','nodeBorder':'#4338ca','nodeTextColor':'#1e1b4b','edgeLabelBackground':'#e0e7ff','attributeBackgroundColorOdd':'#eef2ff','attributeBackgroundColorEven':'#e0e7ff','noteBkgColor':'#fef3c7','noteTextColor':'#1a1a1a','noteBorderColor':'#b45309','clusterBkg':'#f5f3ff','clusterBorder':'#4338ca','labelBoxBkgColor':'#e0e7ff','labelBoxBorderColor':'#4338ca','labelTextColor':'#1e1b4b','actorBkg':'#c7d2fe','actorBorder':'#4338ca','actorTextColor':'#1e1b4b','actorLineColor':'#4338ca','signalColor':'#4338ca','signalTextColor':'#1e1b4b','sequenceNumberColor':'#ffffff','activationBkgColor':'#ddd6fe','activationBorderColor':'#4338ca','transitionColor':'#4338ca','transitionLabelColor':'#1e1b4b','stateBkg':'#c7d2fe','stateLabelColor':'#1e1b4b','altBackground':'#f5f3ff','compositeBackground':'#f5f3ff','compositeBorder':'#4338ca','compositeTitleBackground':'#e0e7ff','specialStateColor':'#4338ca','innerEndBackground':'#4338ca'}}}%%
sequenceDiagram
    autonumber
    participant P as Провайдер
    participant G as IdempotencyGuard
    participant DB as Таблица квитанций
    participant H as Ваш обработчик

    P->>G: callback (id = A)
    G->>DB: INSERT (provider, A)
    DB-->>G: вставлено
    G->>H: выполнить
    H-->>G: ответ
    G->>DB: status = completed, сохранить ответ
    G-->>P: 200 OK

    Note over P,G: ответ потерялся в сети

    P->>G: тот же callback (id = A)
    G->>DB: INSERT (provider, A)
    DB-->>G: нарушение UNIQUE
    G->>DB: SELECT квитанцию
    DB-->>G: completed + сохранённый ответ
    G-->>P: 200 OK, обработчик не запускался
Loading

Вторую вставку отклоняет база, а не код. Проверка «сначала SELECT, потом INSERT если нет» проигрывает гонку: две доставки, пришедшие в одну миллисекунду, обе проходят такую проверку и обе начинают работу.


📐 Три правила, на которых всё держится

1️⃣ Уникальность в схеме

UNIQUE (provider, external_id) — на уровне таблицы, не в коде. Код проверит и проиграет гонку, индекс не проиграет никогда.

2️⃣ Идентификатор — от провайдера

Транзакция, update_id, event_id. Сгенерированный локально id разный на каждой доставке, и каждый повтор выглядит новым событием.

3️⃣ Повтор отвечает 200

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


🚧 Что делает обработчик, который не дожил до конца

Воркера убил деплой, контейнер заменили, процесс упал. Квитанция осталась в processing, и без отдельного правила это событие заблокировано навсегда.

// Захват считается протухшим через 300 секунд по умолчанию.
$guard = new IdempotencyGuard($store, new SystemClock(), staleAfterSeconds: 600);

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

Пока захват свежий, второй воркер получает RequestInProgress. Это не повод ответить 200: данные ещё не записаны, и 200 сказал бы провайдеру, что всё готово. Отвечайте 409 или 503 — провайдер вернётся.


📚 API

Класс Зачем
IdempotencyGuard run(RequestKey, callable) — весь сценарий: захват, выполнение, сохранение, повтор
RequestKey Личность доставки: провайдер + идентификатор от провайдера. Валидирует длину под индекс
Outcome ->response, ->isReplay, ->attempts — что вернуть провайдеру и что записать в лог
Receipt Состояние доставки в хранилище, включая число попыток и текст ошибки
PdoStore Квитанции в таблице MySQL или SQLite
InMemoryStore Для тестов и однопроцессного воркера — не для php-fpm
Schema DDL таблицы под нужный драйвер
RequestInProgress Доставку держит другой воркер
Остальные методы гвардиана
// Уже обработано? Без захвата — для дашборда и логов, не вместо run().
$guard->alreadyHandled($key);

// Уборка: удалить завершённые квитанции старше срока хранения.
$guard->purge(retentionDays: 30);

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


🗄️ Таблица

use Ecomdev\WebhookIdempotency\Store\Schema;

Schema::createTable($pdo);              // быстрый старт
Schema::statements('mysql');            // или готовый DDL в вашу миграцию
Поле Тип Смысл
provider VARCHAR(160) Часть составного ключа: один и тот же id у Payme и Click — разные события
external_id VARCHAR(160) Идентификатор доставки, присланный провайдером
status VARCHAR(16) processing · completed · failed
response MEDIUMTEXT Тело ответа, которое проиграется повтору дословно
attempts INT Заодно оптимистическая блокировка при перехвате протухшего захвата
claimed_at DATETIME От него считается протухание
finished_at DATETIME По нему работает уборка

Индексы: UNIQUE (provider, external_id), (status, claimed_at), (finished_at).


📦 Установка

composer config repositories.webhook-idempotency vcs https://github.com/Shohruh1997/webhook-idempotency-php
composer require ecomdev/webhook-idempotency:dev-main

Требуется PHP 8.1+, ext-pdo, ext-json. Сторонних пакетов нет — ни одного.


🧪 Тесты

php tests/run.php

Без composer install: у библиотеки нет зависимостей, поэтому и у тестов их нет. 68 проверок, каждый сценарий прогоняется дважды — против InMemoryStore и против реального SQLite через PDO, включая то самое нарушение UNIQUE, на котором держится вся конструкция.

CI гоняет матрицу PHP 8.1 · 8.2 · 8.3 · 8.4.


🚫 Чего библиотека не делает

Честная граница важнее длинного списка возможностей:

  • Не проверяет подпись. У каждого провайдера своя схема; проверка должна идти до разбора тела запроса и не зависит от идемпотентности.
  • Не управляет вашей транзакцией. Захват намеренно живёт вне неё: он должен стать виден другим соединениям сразу, иначе второй воркер его не заметит.
  • Не разбирает payload и не знает про заказы. Что делать с событием — ваша бизнес-логика, библиотека решает только «делать ли вообще».
  • Не очередь. Если обработка долгая, внутри замыкания ставьте задачу в очередь, а не тяните вебхук.

🔗 Разборы по теме


Шохрух Рузиев · backend-разработчик, Ташкент

Сайт Telegram

MIT · берите и используйте

About

PHP-библиотека: обработчик вебхука выполняется ровно один раз, сколько бы раз провайдер его ни прислал. Захват через UNIQUE-индекс, повтор отвечает сохранённым ответом. Без зависимостей, MySQL и SQLite.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages