*/ public function backoff(): array { return [15, 60, 300]; } public function handle(): void { $notificationAction = NotificationAction::query()->find($this->notificationActionId); if ($notificationAction === null) { return; } if (! in_array($notificationAction->channel, [NotificationChannel::Discord, NotificationChannel::Webhook], true)) { return; } $deliveryLog = $this->resolveDeliveryLog($notificationAction); $attemptCount = $this->attempts(); $attemptedAt = CarbonImmutable::now(); $discordMessage = null; if ($notificationAction->channel === NotificationChannel::Discord) { $discordMessage = app(RenderDiscordMessageTemplate::class)->handle( $notificationAction->message_template, $this->payload, ); $validationError = $this->discordMessageValidationError($discordMessage); if ($validationError !== null) { $this->markFailedWithoutResponse($deliveryLog, $attemptCount, $attemptedAt, $validationError); $this->fail(new \RuntimeException($validationError)); return; } } try { $response = match ($notificationAction->channel) { NotificationChannel::Discord => $this->sendDiscordRequest($notificationAction, $discordMessage ?? ''), NotificationChannel::Webhook => $this->sendWebhookRequest($notificationAction), }; } catch (ConnectionException $exception) { $this->handleConnectionFailure($deliveryLog, $notificationAction, $attemptCount, $attemptedAt, $exception); return; } if ($response->successful()) { $this->markSucceeded($deliveryLog, $notificationAction, $response, $attemptCount, $attemptedAt); return; } if ($this->shouldRetry($response) && $this->hasAttemptsRemaining($attemptCount)) { $this->markRetryable($deliveryLog, $notificationAction, $response, $attemptCount, $attemptedAt); $this->release($this->backoffDelay($attemptCount)); return; } $this->markFailed($deliveryLog, $notificationAction, $response, $attemptCount, $attemptedAt); $this->fail(new \RuntimeException($this->responseFailureDescription($notificationAction, $response))); } public function failed(?Throwable $exception): void { $deliveryLog = $this->loadDeliveryLog(); if ($deliveryLog === null || $deliveryLog->delivery_status === NotificationDeliveryStatus::Succeeded) { return; } $notificationAction = NotificationAction::query()->find($this->notificationActionId); $deliveryLog->update([ 'attempt_count' => max($deliveryLog->attempt_count, $this->attempts()), 'delivery_status' => NotificationDeliveryStatus::Failed, 'delivered_at' => null, 'error_excerpt' => $deliveryLog->error_excerpt ?? $this->sanitizedExcerpt( $exception?->getMessage(), $notificationAction, ), 'last_attempted_at' => $deliveryLog->last_attempted_at ?? CarbonImmutable::now(), ]); } private function handleConnectionFailure( NotificationDeliveryLog $deliveryLog, NotificationAction $notificationAction, int $attemptCount, CarbonImmutable $attemptedAt, ConnectionException $exception, ): void { $errorExcerpt = $this->sanitizedExcerpt($exception->getMessage(), $notificationAction) ?? $this->requestFailureDescription($notificationAction->channel); 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, NotificationAction $notificationAction, 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, $notificationAction), ]); } private function markRetryable( NotificationDeliveryLog $deliveryLog, NotificationAction $notificationAction, Response $response, int $attemptCount, CarbonImmutable $attemptedAt, ): void { $deliveryLog->update([ 'attempt_count' => $attemptCount, 'delivery_status' => NotificationDeliveryStatus::Retryable, 'delivered_at' => null, 'error_excerpt' => $this->responseFailureDescription($notificationAction, $response), 'http_status' => $response->status(), 'last_attempted_at' => $attemptedAt, 'response_excerpt' => $this->responseExcerpt($response, $notificationAction), ]); } private function markFailed( NotificationDeliveryLog $deliveryLog, NotificationAction $notificationAction, Response $response, int $attemptCount, CarbonImmutable $attemptedAt, ): void { $deliveryLog->update([ 'attempt_count' => $attemptCount, 'delivery_status' => NotificationDeliveryStatus::Failed, 'delivered_at' => null, 'error_excerpt' => $this->responseFailureDescription($notificationAction, $response), 'http_status' => $response->status(), 'last_attempted_at' => $attemptedAt, 'response_excerpt' => $this->responseExcerpt($response, $notificationAction), ]); } private function markFailedWithoutResponse( NotificationDeliveryLog $deliveryLog, int $attemptCount, CarbonImmutable $attemptedAt, string $errorExcerpt, ): void { $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, ]); } 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 sendDiscordRequest(NotificationAction $notificationAction, string $message): Response { return Http::acceptJson() ->asJson() ->connectTimeout(5) ->timeout(10) ->post($notificationAction->delivery_url, [ 'content' => $message, 'allowed_mentions' => [ 'parse' => [], ], ]); } private function sendWebhookRequest(NotificationAction $notificationAction): Response { return Http::acceptJson() ->asJson() ->connectTimeout(5) ->timeout(10) ->post($notificationAction->delivery_url, $this->payload->toArray()); } 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, NotificationAction $notificationAction): ?string { return $this->sanitizedExcerpt($response->body(), $notificationAction) ?? $this->sanitizedExcerpt($response->reason(), $notificationAction); } private function responseFailureDescription(NotificationAction $notificationAction, Response $response): string { $reason = $this->sanitizedExcerpt($response->reason(), $notificationAction); $subject = $this->deliverySubject($notificationAction->channel); if ($reason === null) { return "{$subject} returned HTTP {$response->status()}."; } return "{$subject} returned HTTP {$response->status()} {$reason}."; } private function requestFailureDescription(NotificationChannel $channel): string { return match ($channel) { NotificationChannel::Discord => 'Discord webhook request failed.', NotificationChannel::Webhook => 'Webhook request failed.', }; } private function deliverySubject(NotificationChannel $channel): string { return match ($channel) { NotificationChannel::Discord => 'Discord webhook', NotificationChannel::Webhook => 'Webhook', }; } private function discordMessageValidationError(string $message): ?string { if (trim($message) === '') { return 'Discord message content cannot be empty.'; } if (Str::length($message) > self::DISCORD_CONTENT_LIMIT) { return 'Discord message content exceeds the 2000 character limit.'; } return null; } /** * @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; } private function sanitizedExcerpt(?string $value, ?NotificationAction $notificationAction): ?string { if ( $value !== null && $notificationAction !== null && filled($notificationAction->delivery_url) ) { $value = str_replace( [ $notificationAction->delivery_url, str_replace('/', '\/', $notificationAction->delivery_url), ], '[redacted]', $value, ); } return $this->excerpt($value); } }