diff --git a/app-modules/integration-discord/src/Sync/Actions/PurgeUnusedInvitesAction.php b/app-modules/integration-discord/src/Sync/Actions/PurgeUnusedInvitesAction.php index dc93aba9..3105022f 100644 --- a/app-modules/integration-discord/src/Sync/Actions/PurgeUnusedInvitesAction.php +++ b/app-modules/integration-discord/src/Sync/Actions/PurgeUnusedInvitesAction.php @@ -6,9 +6,11 @@ use He4rt\IntegrationDiscord\Sync\DTOs\MatchedInviteDTO; use He4rt\IntegrationDiscord\Sync\DTOs\PurgeInvitesResultDTO; +use He4rt\IntegrationDiscord\Sync\Exceptions\CloudflareIpBanException; use He4rt\IntegrationDiscord\Transport\DiscordConnector; use He4rt\IntegrationDiscord\Transport\Requests\Invites\DeleteInvite; use He4rt\IntegrationDiscord\Transport\Requests\Invites\ListGuildInvites; +use Illuminate\Support\Arr; use Illuminate\Support\Facades\Log; use Illuminate\Support\Sleep; use JsonException; @@ -16,6 +18,7 @@ use RuntimeException; use Saloon\Exceptions\Request\FatalRequestException; use Saloon\Exceptions\Request\RequestException; +use Saloon\Http\Response; use Throwable; final readonly class PurgeUnusedInvitesAction @@ -69,6 +72,14 @@ public function execute(string $guildId, bool $dryRun = false, bool $includeExpi try { $this->deleteInvite($invite['code']); $deleted++; + } catch (CloudflareIpBanException $e) { + $failed += count($unused) - $index; + Log::warning('Cloudflare IP ban detected, aborting purge', [ + 'code' => $invite['code'], + 'error' => $e->getMessage(), + ]); + + break; } catch (Throwable $e) { $failed++; Log::warning('Failed to delete Discord invite', [ @@ -97,6 +108,7 @@ private function jitteredSleep(float $baseSeconds = 0.0): void } /** + * @throws CloudflareIpBanException * @throws RandomException * @throws FatalRequestException * @throws RequestException @@ -111,14 +123,44 @@ private function deleteInvite(string $code): void return; } - if ($response->status() === 429 && $attempt < self::MAX_RETRIES) { - $retryAfter = (float) ($response->json('retry_after') ?? 1.0); - $this->jitteredSleep($retryAfter); + if ($response->status() === 429) { + $retryAfter = $this->parseRetryAfter($response); + + if ($retryAfter > 60.0) { + throw new CloudflareIpBanException(sprintf('Retry-After %ds', (int) $retryAfter)); + } - continue; + if ($attempt < self::MAX_RETRIES) { + $this->jitteredSleep($retryAfter); + + continue; + } } throw new RuntimeException(sprintf('HTTP %d: %s', $response->status(), $response->body())); } } + + private function parseRetryAfter(Response $response): float + { + $retryAfter = Arr::get(json_decode($response->body(), true), 'retry_after'); + + if ($retryAfter !== null) { + return (float) $retryAfter; + } + + $resetAfter = $response->header('X-RateLimit-Reset-After'); + + if ($resetAfter !== '' && is_numeric($resetAfter)) { + return (float) $resetAfter; + } + + $header = $response->header('Retry-After'); + + if ($header !== '' && is_numeric($header)) { + return (float) $header; + } + + return 1.0; + } } diff --git a/app-modules/integration-discord/src/Sync/Exceptions/CloudflareIpBanException.php b/app-modules/integration-discord/src/Sync/Exceptions/CloudflareIpBanException.php new file mode 100644 index 00000000..d5de4c33 --- /dev/null +++ b/app-modules/integration-discord/src/Sync/Exceptions/CloudflareIpBanException.php @@ -0,0 +1,9 @@ + 'You are being rate limited.'], 429, ['X-RateLimit-Reset-After' => '2.57']), + MockResponse::make([], 204), + ]); + + $connector = new DiscordConnector('test-token'); + $connector->withMockClient($mockClient); + + $action = new PurgeUnusedInvitesAction($connector); + $result = $action->execute('guild-123', dryRun: false); + + expect($result->deleted)->toBe(1) + ->and($result->failed)->toBe(0); +}); + +it('retries on cloudflare 429 with non-json body and short retry-after header', function (): void { + Sleep::fake(); + + $invites = [ + makeInvite('aaa', maxAge: 0, uses: 0), + ]; + + $mockClient = new MockClient([ + MockResponse::make($invites), + MockResponse::make('error code: 1015', 429, ['Retry-After' => '5']), + MockResponse::make([], 204), + ]); + + $connector = new DiscordConnector('test-token'); + $connector->withMockClient($mockClient); + + $action = new PurgeUnusedInvitesAction($connector); + $result = $action->execute('guild-123', dryRun: false); + + expect($result->deleted)->toBe(1) + ->and($result->failed)->toBe(0); +}); + +it('aborts entire purge on cloudflare ip ban', function (): void { + Sleep::fake(); + + $invites = [ + makeInvite('aaa', maxAge: 0, uses: 0), + makeInvite('bbb', maxAge: 0, uses: 0), + makeInvite('ccc', maxAge: 0, uses: 0), + ]; + + $mockClient = new MockClient([ + MockResponse::make($invites), + MockResponse::make([], 204), + MockResponse::make('error code: 1015', 429, ['Retry-After' => '80974']), + ]); + + $connector = new DiscordConnector('test-token'); + $connector->withMockClient($mockClient); + + $action = new PurgeUnusedInvitesAction($connector); + $result = $action->execute('guild-123', dryRun: false); + + expect($result->deleted)->toBe(1) + ->and($result->failed)->toBe(2); + + $mockClient->assertSentCount(3); + Sleep::assertSleptTimes(1); +}); + it('fails after exhausting retries on persistent 429', function (): void { Sleep::fake();