Implement generic webhook delivery
This commit is contained in:
@@ -2,17 +2,278 @@
|
||||
|
||||
namespace App\Actions\Notifications;
|
||||
|
||||
use App\Enums\NotificationChannel;
|
||||
use App\Enums\NotificationDeliveryStatus;
|
||||
use App\Models\NotificationAction;
|
||||
use App\Models\NotificationDeliveryLog;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Illuminate\Http\Client\ConnectionException;
|
||||
use Illuminate\Http\Client\Response;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Str;
|
||||
use Throwable;
|
||||
|
||||
class DeliverNotificationAction implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
public int $timeout = 15;
|
||||
|
||||
public function __construct(
|
||||
public int $notificationActionId,
|
||||
public TicketStatusChangedPayload $payload,
|
||||
public ?int $notificationDeliveryLogId,
|
||||
) {}
|
||||
|
||||
public function handle(): void {}
|
||||
/**
|
||||
* @return list<int>
|
||||
*/
|
||||
public function backoff(): array
|
||||
{
|
||||
return [15, 60, 300];
|
||||
}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$notificationAction = NotificationAction::query()->find($this->notificationActionId);
|
||||
|
||||
if ($notificationAction === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($notificationAction->channel !== NotificationChannel::Webhook) {
|
||||
return;
|
||||
}
|
||||
|
||||
$deliveryLog = $this->resolveDeliveryLog($notificationAction);
|
||||
$attemptCount = $this->attempts();
|
||||
$attemptedAt = CarbonImmutable::now();
|
||||
|
||||
try {
|
||||
$response = Http::acceptJson()
|
||||
->asJson()
|
||||
->connectTimeout(5)
|
||||
->timeout(10)
|
||||
->post($notificationAction->delivery_url, $this->payload->toArray());
|
||||
} catch (ConnectionException $exception) {
|
||||
$this->handleConnectionFailure($deliveryLog, $attemptCount, $attemptedAt, $exception);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($response->successful()) {
|
||||
$this->markSucceeded($deliveryLog, $response, $attemptCount, $attemptedAt);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->shouldRetry($response) && $this->hasAttemptsRemaining($attemptCount)) {
|
||||
$this->markRetryable($deliveryLog, $response, $attemptCount, $attemptedAt);
|
||||
$this->release($this->backoffDelay($attemptCount));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->markFailed($deliveryLog, $response, $attemptCount, $attemptedAt);
|
||||
$this->fail(new \RuntimeException($this->responseFailureDescription($response)));
|
||||
}
|
||||
|
||||
public function failed(?Throwable $exception): void
|
||||
{
|
||||
$deliveryLog = $this->loadDeliveryLog();
|
||||
|
||||
if ($deliveryLog === null || $deliveryLog->delivery_status === NotificationDeliveryStatus::Succeeded) {
|
||||
return;
|
||||
}
|
||||
|
||||
$deliveryLog->update([
|
||||
'attempt_count' => max($deliveryLog->attempt_count, $this->attempts()),
|
||||
'delivery_status' => NotificationDeliveryStatus::Failed,
|
||||
'delivered_at' => null,
|
||||
'error_excerpt' => $deliveryLog->error_excerpt ?? $this->excerpt($exception?->getMessage()),
|
||||
'last_attempted_at' => $deliveryLog->last_attempted_at ?? CarbonImmutable::now(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function handleConnectionFailure(
|
||||
NotificationDeliveryLog $deliveryLog,
|
||||
int $attemptCount,
|
||||
CarbonImmutable $attemptedAt,
|
||||
ConnectionException $exception,
|
||||
): void {
|
||||
$errorExcerpt = $this->excerpt($exception->getMessage()) ?? 'Webhook request failed.';
|
||||
|
||||
if ($this->hasAttemptsRemaining($attemptCount)) {
|
||||
$deliveryLog->update([
|
||||
'attempt_count' => $attemptCount,
|
||||
'delivery_status' => NotificationDeliveryStatus::Retryable,
|
||||
'delivered_at' => null,
|
||||
'error_excerpt' => $errorExcerpt,
|
||||
'http_status' => null,
|
||||
'last_attempted_at' => $attemptedAt,
|
||||
'response_excerpt' => null,
|
||||
]);
|
||||
|
||||
$this->release($this->backoffDelay($attemptCount));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$deliveryLog->update([
|
||||
'attempt_count' => $attemptCount,
|
||||
'delivery_status' => NotificationDeliveryStatus::Failed,
|
||||
'delivered_at' => null,
|
||||
'error_excerpt' => $errorExcerpt,
|
||||
'http_status' => null,
|
||||
'last_attempted_at' => $attemptedAt,
|
||||
'response_excerpt' => null,
|
||||
]);
|
||||
|
||||
$this->fail($exception);
|
||||
}
|
||||
|
||||
private function markSucceeded(
|
||||
NotificationDeliveryLog $deliveryLog,
|
||||
Response $response,
|
||||
int $attemptCount,
|
||||
CarbonImmutable $attemptedAt,
|
||||
): void {
|
||||
$deliveryLog->update([
|
||||
'attempt_count' => $attemptCount,
|
||||
'delivery_status' => NotificationDeliveryStatus::Succeeded,
|
||||
'delivered_at' => $attemptedAt,
|
||||
'error_excerpt' => null,
|
||||
'http_status' => $response->status(),
|
||||
'last_attempted_at' => $attemptedAt,
|
||||
'response_excerpt' => $this->responseExcerpt($response),
|
||||
]);
|
||||
}
|
||||
|
||||
private function markRetryable(
|
||||
NotificationDeliveryLog $deliveryLog,
|
||||
Response $response,
|
||||
int $attemptCount,
|
||||
CarbonImmutable $attemptedAt,
|
||||
): void {
|
||||
$deliveryLog->update([
|
||||
'attempt_count' => $attemptCount,
|
||||
'delivery_status' => NotificationDeliveryStatus::Retryable,
|
||||
'delivered_at' => null,
|
||||
'error_excerpt' => $this->responseFailureDescription($response),
|
||||
'http_status' => $response->status(),
|
||||
'last_attempted_at' => $attemptedAt,
|
||||
'response_excerpt' => $this->responseExcerpt($response),
|
||||
]);
|
||||
}
|
||||
|
||||
private function markFailed(
|
||||
NotificationDeliveryLog $deliveryLog,
|
||||
Response $response,
|
||||
int $attemptCount,
|
||||
CarbonImmutable $attemptedAt,
|
||||
): void {
|
||||
$deliveryLog->update([
|
||||
'attempt_count' => $attemptCount,
|
||||
'delivery_status' => NotificationDeliveryStatus::Failed,
|
||||
'delivered_at' => null,
|
||||
'error_excerpt' => $this->responseFailureDescription($response),
|
||||
'http_status' => $response->status(),
|
||||
'last_attempted_at' => $attemptedAt,
|
||||
'response_excerpt' => $this->responseExcerpt($response),
|
||||
]);
|
||||
}
|
||||
|
||||
private function resolveDeliveryLog(NotificationAction $notificationAction): NotificationDeliveryLog
|
||||
{
|
||||
$deliveryLog = $this->loadDeliveryLog();
|
||||
|
||||
if ($deliveryLog !== null) {
|
||||
return $deliveryLog;
|
||||
}
|
||||
|
||||
$deliveryLog = NotificationDeliveryLog::query()->create([
|
||||
'attempt_count' => 0,
|
||||
'channel' => $notificationAction->channel,
|
||||
'delivery_status' => NotificationDeliveryStatus::Pending,
|
||||
'event_type' => $this->payload->eventType,
|
||||
'notification_action_id' => $notificationAction->id,
|
||||
'payload_metadata' => $this->payloadMetadata(),
|
||||
]);
|
||||
|
||||
$this->notificationDeliveryLogId = $deliveryLog->id;
|
||||
|
||||
return $deliveryLog;
|
||||
}
|
||||
|
||||
private function loadDeliveryLog(): ?NotificationDeliveryLog
|
||||
{
|
||||
if ($this->notificationDeliveryLogId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return NotificationDeliveryLog::query()
|
||||
->whereKey($this->notificationDeliveryLogId)
|
||||
->where('notification_action_id', $this->notificationActionId)
|
||||
->first();
|
||||
}
|
||||
|
||||
private function shouldRetry(Response $response): bool
|
||||
{
|
||||
return $response->serverError() || in_array($response->status(), [408, 429], true);
|
||||
}
|
||||
|
||||
private function hasAttemptsRemaining(int $attemptCount): bool
|
||||
{
|
||||
return $attemptCount < $this->tries;
|
||||
}
|
||||
|
||||
private function backoffDelay(int $attemptCount): int
|
||||
{
|
||||
$backoff = $this->backoff();
|
||||
$index = min($attemptCount - 1, count($backoff) - 1);
|
||||
|
||||
return $backoff[$index];
|
||||
}
|
||||
|
||||
private function responseExcerpt(Response $response): ?string
|
||||
{
|
||||
return $this->excerpt($response->body()) ?? $this->excerpt($response->reason());
|
||||
}
|
||||
|
||||
private function responseFailureDescription(Response $response): string
|
||||
{
|
||||
$reason = $this->excerpt($response->reason());
|
||||
|
||||
if ($reason === null) {
|
||||
return "Webhook returned HTTP {$response->status()}.";
|
||||
}
|
||||
|
||||
return "Webhook returned HTTP {$response->status()} {$reason}.";
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{project_key: string, ticket_key: string}
|
||||
*/
|
||||
private function payloadMetadata(): array
|
||||
{
|
||||
return [
|
||||
'project_key' => $this->payload->projectKey,
|
||||
'ticket_key' => $this->payload->ticketKey,
|
||||
];
|
||||
}
|
||||
|
||||
private function excerpt(?string $value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$excerpt = (string) Str::of($value)->trim()->squish()->limit(500);
|
||||
|
||||
return $excerpt === '' ? null : $excerpt;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user