agent/php/Mod/Api/Jobs/DeliverWebhookJob.php
Snider 5385385314 feat(agent/api): RFC foundation — API keys, webhooks, rate limiting, docs split
Foundation slice for Mantis #844 php/Mod/Api RFC implementation:

* New php/Mod/Api/ package: Boot, Controllers, Documentation, Jobs,
  Middleware, Models, RateLimit, Routes, Services
* Models: ApiKey, WebhookEndpoint, WebhookDelivery
* WebhookService::dispatch() with DB::transaction + afterCommit
* DeliverWebhookJob with retry/backoff
* WebhookSignature with timing-safe verification + 5-minute tolerance +
  dual-secret rotation support
* Sliding-window rate limiter in RateLimit/RateLimitService.php
* AuthenticateApiKey middleware: hk_ prefix + Sanctum fallback
* DocsController / DocumentationController split
* 3 root migrations: api_keys, webhook_endpoints, webhook_deliveries
* Foundation tests under php/tests/Feature/Mod/Api/
* FOLLOWUP.md tracks remaining RFC scope

php -l clean across 21 PHP files. Pest unrunnable in sandbox (no vendor/).

Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=844
2026-04-25 21:01:54 +01:00

82 lines
2.4 KiB
PHP

<?php
// SPDX-License-Identifier: EUPL-1.2
declare(strict_types=1);
namespace Core\Mod\Agentic\Mod\Api\Jobs;
use Core\Mod\Agentic\Mod\Api\Models\WebhookDelivery;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
class DeliverWebhookJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public bool $deleteWhenMissingModels = true;
public int $tries = 1;
public function __construct(
public WebhookDelivery $delivery
) {}
public function handle(): void
{
$delivery = $this->delivery->fresh(['endpoint']);
if (! $delivery instanceof WebhookDelivery) {
return;
}
$endpoint = $delivery->endpoint;
if ($endpoint === null || ! $endpoint->shouldReceive($delivery->event_type)) {
$delivery->forceFill(['status' => WebhookDelivery::STATUS_CANCELLED])->save();
return;
}
$delivery->forceFill(['status' => WebhookDelivery::STATUS_QUEUED])->save();
$payload = $delivery->getDeliveryPayload();
try {
$response = Http::timeout(10)
->withHeaders($payload['headers'])
->withBody($payload['body'], 'application/json')
->post($endpoint->url);
if ($response->successful()) {
$delivery->markSuccess($response->status(), $response->body());
return;
}
$this->handleFailure($delivery, $response->status(), $response->body());
} catch (ConnectionException $exception) {
$this->handleFailure($delivery, 0, 'Connection failed: '.$exception->getMessage());
} catch (\Throwable $exception) {
$this->handleFailure($delivery, 0, 'Unexpected error: '.$exception->getMessage());
}
}
protected function handleFailure(WebhookDelivery $delivery, int $statusCode, ?string $responseBody): void
{
$delivery->markFailed($statusCode, $responseBody);
$delivery->refresh();
if (! $delivery->canRetry() || $delivery->next_retry_at === null) {
return;
}
self::dispatch($delivery->fresh())->delay($delivery->next_retry_at);
}
}