Preskočiť na obsah

Webhooky s opakovaním ​

Cieľ: posielať podpísané udalosti cez POST na URL vašich zákazníkov. Keď príjemca nefunguje, opakovať s rastúcim oneskorením. Pri odpovediach, ktoré sa nezlepšia, to vzdať.

Súbory ​

Redis a src/Hermes.php ako v návode na uvítací e-mail.

json
{
    "require": {
        "php": ">=8.4",
        "tomaj/hermes": "dev-master",
        "predis/predis": "^2.3 || ^3.0",
        "symfony/http-client": "^7.0",
        "monolog/monolog": "^3.0"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "minimum-stability": "dev",
    "prefer-stable": true
}
php
<?php

declare(strict_types=1);

use App\Hermes;
use Tomaj\Hermes\Message;

require __DIR__ . '/../vendor/autoload.php';

// Something happened in your app, for example an order was shipped.
$event = [
    'event' => 'order.shipped',
    'order_id' => 1042,
    'shipped_at' => date(DATE_ATOM),
];

Hermes::emitter()->emit(new Message('webhook.deliver', [
    'url' => getenv('WEBHOOK_URL') ?: 'http://127.0.0.1:9000/',
    'secret' => 'whsec_change_me',
    'body' => $event,
]));

http_response_code(202);
echo "Webhook queued.\n";
php
<?php

declare(strict_types=1);

namespace App;

use RuntimeException;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Tomaj\Hermes\Handler\RetryableHandlerInterface;
use Tomaj\Hermes\Handler\RetryTrait;
use Tomaj\Hermes\MessageInterface;

final class WebhookHandler implements RetryableHandlerInterface
{
    use RetryTrait;

    public function __construct(private HttpClientInterface $http) {}

    #[\Override]
    public function handle(MessageInterface $message): bool
    {
        $payload = $message->getPayload() ?? [];
        $body = json_encode($payload['body'], JSON_THROW_ON_ERROR);
        $timestamp = (string) time();

        $response = $this->http->request('POST', $payload['url'], [
            'headers' => [
                'Content-Type' => 'application/json',
                // The message id stays the same on every retry. Receivers use it to drop duplicates.
                'Webhook-Id' => $message->getId(),
                'Webhook-Attempt' => (string) ($message->getRetries() + 1),
                'Webhook-Timestamp' => $timestamp,
                'Webhook-Signature' => hash_hmac('sha256', "{$timestamp}.{$body}", $payload['secret']),
            ],
            'body' => $body,
            'timeout' => 10,
        ]);

        $status = $response->getStatusCode(); // waits for the response
        if ($status >= 500 || $status === 429) {
            // Throwing makes Hermes send a copy with a later executeAt.
            throw new RuntimeException("Receiver answered {$status}");
        }

        // 2xx is delivered. Any other 4xx will not get better on a retry, so give up.
        return $status < 300;
    }

    public function maxRetry(): int
    {
        return 8; // last attempt roughly an hour and a half after the first one
    }
}
php
<?php

declare(strict_types=1);

use App\Hermes;
use App\WebhookHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Logger;
use Symfony\Component\HttpClient\HttpClient;
use Tomaj\Hermes\Dispatcher;
use Tomaj\Hermes\Shutdown\PredisShutdown;

require __DIR__ . '/../vendor/autoload.php';

$redis = Hermes::redis();
$driver = Hermes::driver($redis);
$driver->setShutdown(new PredisShutdown($redis));

$dispatcher = new Dispatcher($driver, new Logger('webhooks', [new StreamHandler('php://stdout')]));
$dispatcher->registerHandler('webhook.deliver', new WebhookHandler(HttpClient::create()));
$dispatcher->handle();

Falošný príjemca na lokálne testovanie. Na prvý pokus odpovie 503 a na druhý 204:

php
<?php

declare(strict_types=1);

// A fake receiver for local testing: fails the first attempt with 503, accepts the next one.
$attempt = (int) ($_SERVER['HTTP_WEBHOOK_ATTEMPT'] ?? 1);
$expected = hash_hmac('sha256', $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] . '.' . file_get_contents('php://input'), 'whsec_change_me');
$valid = hash_equals($expected, $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '');

error_log(sprintf('webhook %s attempt %d signature %s', $_SERVER['HTTP_WEBHOOK_ID'] ?? '?', $attempt, $valid ? 'ok' : 'BAD'));
http_response_code($attempt === 1 ? 503 : 204);

Spustenie ​

bash
composer install
docker compose up -d redis
php -S 127.0.0.1:9000 receiver.php     # terminal 1: the receiver
php bin/worker.php                     # terminal 2: the worker
php public/index.php                   # terminal 3: emit one event

Príjemca zaloguje prvý pokus hneď a druhý o 16 až 45 sekúnd neskôr, s tým istým Webhook-Id:

text
webhook d8299c53-… attempt 1 signature ok
webhook d8299c53-… attempt 2 signature ok

Worker medzi nimi zaloguje Handler App\WebhookHandler throws exception - Receiver answered 503.

Úskalia ​

  • Rozhodnite, ktoré zlyhania opakovať. Výnimka opakuje, vrátenie false nie. Timeout, 5xx alebo 429 môžu neskôr uspieť. 404 ani 410 nie.
  • Posielajte stabilné id. Dispatcher ponechá id správy na každej kópii na opakovanie. Posielajte ho v hlavičke, aby príjemcovia mohli zahodiť duplicity, pretože timeout môže skryť požiadavku, ktorá dorazila.
  • Oneskorenie opakovania určuje Hermes. Je to retries⁴ + 15 + jitter × (retries + 1) sekúnd, s náhodnou zložkou od 1 do 30. S maxRetry() 8 je posledný pokus asi hodinu a pol po prvom.
  • Opakovanie potrebuje driver s oneskorením. Na RabbitMQ, SQS a Kafka sa kópia publikuje hneď, takže všetky pokusy prebehnú v priebehu sekúnd. Na webhooky použite Predis.
  • Nastavte timeout. Jeden pomalý príjemca nesmie zablokovať workera. Desať sekúnd stačí a viac workerov udrží ostatné doručenia v pohybe.

Celý zdrojový kód ​

Všetky súbory tohto receptu, s README.md, Makefile a kontrolou e2e.sh, sú v examples/webhooks na GitHube. Naklonujte repozitár, spustite cd examples/webhooks a make install up, potom make e2e.

Časté otázky ​

Ktoré odpovede sa opakujú? ​

Timeouty, 5xx a 429 vyhodia výnimku, takže sa opakujú. Ostatné 4xx vrátia false a neopakujú sa.

Ako príjemcovia zahodia duplicity? ​

Podľa hlavičky Webhook-Id. Je to id správy, ktoré zostáva rovnaké pri každom opakovaní.

Ako príjemcovia overia podpis? ​

Vypočítajú hash_hmac('sha256', timestamp . '.' . body, secret) a porovnajú ho cez hash_equals(), ako to robí receiver.php.

Ako dlho to Hermes skúša? ​

S maxRetry() 8 asi hodinu a pol. Vzorec je na stránke Opakovanie.

Môžem na webhooky použiť RabbitMQ? ​

Nie s rastúcim oneskorením. RabbitMQ ignoruje executeAt, takže každé opakovanie prebehne hneď.

Vydané pod licenciou MIT.