Skip to content

WebhookDispatcher + Messenger handler: outbound POST with retry / dead-letter / replay #187

@tonydspaniard

Description

@tonydspaniard

Part of #184. Depends on #185 (storage + signers). Builds on univeros/messaging.

Goal

Add the outbound side of the webhook framework: WebhookDispatcher (the application-facing API) + WebhookMessage (the DTO that travels through Symfony Messenger) + WebhookHandler (the worker-side handler that performs the signed HTTP POST and records delivery state) + RetryPolicy (encodes the backoff curve) + bin/altair webhook:replay <id> (manual re-dispatch).

Why

Outbound webhooks are the third-party-integration primitive. An application emits an event; the framework signs it, dispatches it via the existing queue infrastructure, retries on transient failures, dead-letters after the configured threshold, records the delivery state so operators (and agents) can inspect and replay. Building this on top of univeros/messaging reuses the queue + retry + DLQ wiring the framework already ships rather than re-inventing it.

Flow

Application:
    $dispatcher->dispatch('order.created', $payload, $subscriberUrl, secretName: 'partner-x');

WebhookDispatcher:
    - Generates delivery id (ULID).
    - Records Delivery row with status=Pending, attempts=0.
    - Builds WebhookMessage{ deliveryId, eventName, payload, subscriberUrl, secretName, signerName }.
    - Dispatches via Altair\Messaging\Contracts\MessageBusInterface.

Worker (Messenger handler):
    - WebhookHandler receives WebhookMessage.
    - Resolves Delivery row.
    - Builds POST request with X-Signature (via SignerInterface) + X-Timestamp + X-Event-Id + X-Delivery-Id headers.
    - Sends via PSR-18 HttpClient.
    - On 2xx: Delivery.status = Delivered; record lastResponse; commit.
    - On 5xx / network failure: increment attempts; if attempts < max → re-throw RetryableException (Messenger retries via its built-in retry strategy); if attempts >= max → Delivery.status = DeadLettered; commit; throw UnrecoverableMessageHandlingException to dead-letter the envelope.

CLI:
    bin/altair webhook:replay <delivery-id>
      → Re-dispatch the failed/dead-lettered delivery; resets attempts to 0; status to Pending.

    bin/altair webhook:show-failed [--limit=N]
      → List deliveries with status=DeadLettered.

Shape

src/Altair/Webhooks/Dispatcher/
\xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 WebhookDispatcher.php          # application-facing
\xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 WebhookMessage.php             # Messenger DTO (readonly)
\xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 WebhookHandler.php             # #[AsHandler(WebhookMessage::class)]
\xe2\x94\x94\xe2\x94\x80\xe2\x94\x80 RetryPolicy.php                # encodes backoff curve; pure value object
src/Altair/Webhooks/Cli/
\xe2\x94\x9c\xe2\x94\x80\xe2\x94\x80 WebhookReplayCommand.php       # bin/altair webhook:replay
\xe2\x94\x94\xe2\x94\x80\xe2\x94\x80 WebhookShowFailedCommand.php   # bin/altair webhook:show-failed

Retry policy

RetryPolicy is a small immutable value object:

final readonly class RetryPolicy
{
    public const string EXPONENTIAL = 'exponential';
    public const string LINEAR = 'linear';

    public function __construct(
        public int $maxAttempts = 5,
        public string $backoff = self::EXPONENTIAL,
        public int $baseDelaySeconds = 30,
    ) {}

    public function delayFor(int $attempt): int
    {
        return match ($this->backoff) {
            self::EXPONENTIAL => $this->baseDelaySeconds * (2 ** ($attempt - 1)),
            self::LINEAR => $this->baseDelaySeconds * $attempt,
            default => $this->baseDelaySeconds,
        };
    }
}

The handler computes the next attempt time as now + delayFor(attemptNumber) and stamps it on the Delivery; the Messenger transport's retry stamp is wired from this value.

Acceptance criteria

  • WebhookDispatcher::dispatch() records a Delivery row in DeliveryStoreInterface + dispatches WebhookMessage via MessageBusInterface
  • WebhookHandler performs the signed POST, updates Delivery.status based on outcome, and uses Symfony Messenger's retry mechanism for transient failures
  • Dead-lettered deliveries get status=DeadLettered and surface in bin/altair webhook:show-failed
  • bin/altair webhook:replay <delivery-id> resolves a delivery (or a unique prefix), resets it to Pending, and re-dispatches the same payload
  • The Action attribute integration: when a spec has webhook: { direction: out }, the scaffolder emits a binding for the dispatcher rather than a middleware (issue #TBD for spec block handles the emission side)
  • 80%+ coverage; tests use an in-memory PSR-18 ClientInterface mock and InMemoryDeliveryStore so they don't hit the network or stand up Redis
  • No commit-time coupling to a specific HTTP client \xe2\x80\x94 PSR-18 interface is the seam; hosts inject their preferred client (Guzzle, Symfony HttpClient, etc.)

Out of scope

  • The webhook: spec block \xe2\x80\x94 separate issue
  • A subscriber-list management surface \xe2\x80\x94 host's call
  • Custom retry curves beyond exponential / linear \xe2\x80\x94 add when a real consumer asks
  • Webhook signature scheme rotation \xe2\x80\x94 swap the signer registry entry; the dispatcher will sign with whatever the spec said

Notes

The handler's interaction with Messenger's retry needs to be careful: throwing a regular exception triggers Messenger's default retry behaviour with the transport's configured curve, which may not match RetryPolicy. Two options to consider during implementation:

a) Embed the next-attempt delay in the exception (RetryableException::withDelay($seconds)) so Messenger respects it.
b) Configure the transport's retry strategy to read from the handler's stored Delivery row.

Pick whichever is less fragile against Messenger version drift. Document the choice in the package docs (#TBD).

WebhookMessage should be a final readonly class decorated with #[AsHandler(WebhookHandler::class)] (or however univeros/messaging registers handlers \xe2\x80\x94 mirror the existing pattern in src/Altair/Messaging/*).

Metadata

Metadata

Assignees

No one assigned

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions