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.
{
"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
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
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
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
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
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 eventPríjemca zaloguje prvý pokus hneď a druhý o 16 až 45 sekúnd neskôr, s tým istým Webhook-Id:
webhook d8299c53-… attempt 1 signature ok
webhook d8299c53-… attempt 2 signature okWorker medzi nimi zaloguje Handler App\WebhookHandler throws exception - Receiver answered 503.
Úskalia
- Rozhodnite, ktoré zlyhania opakovať. Výnimka opakuje, vrátenie
falsenie. Timeout,5xxalebo429môžu neskôr uspieť.404ani410nie. - 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. SmaxRetry()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ď.