diff --git a/app-modules/integration-discord/src/Sync/Actions/PurgeUnusedInvitesAction.php b/app-modules/integration-discord/src/Sync/Actions/PurgeUnusedInvitesAction.php index ee2ebb5a..dc93aba9 100644 --- a/app-modules/integration-discord/src/Sync/Actions/PurgeUnusedInvitesAction.php +++ b/app-modules/integration-discord/src/Sync/Actions/PurgeUnusedInvitesAction.php @@ -4,25 +4,35 @@ namespace He4rt\IntegrationDiscord\Sync\Actions; +use He4rt\IntegrationDiscord\Sync\DTOs\MatchedInviteDTO; +use He4rt\IntegrationDiscord\Sync\DTOs\PurgeInvitesResultDTO; use He4rt\IntegrationDiscord\Transport\DiscordConnector; use He4rt\IntegrationDiscord\Transport\Requests\Invites\DeleteInvite; use He4rt\IntegrationDiscord\Transport\Requests\Invites\ListGuildInvites; -use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\Log; use Illuminate\Support\Sleep; +use JsonException; +use Random\RandomException; use RuntimeException; +use Saloon\Exceptions\Request\FatalRequestException; +use Saloon\Exceptions\Request\RequestException; use Throwable; final readonly class PurgeUnusedInvitesAction { + private const int MAX_RETRIES = 3; + public function __construct( private DiscordConnector $connector, ) {} /** - * @return array{total: int, matched: int, deleted: int, failed: int, invites: list} + * @throws RandomException + * @throws FatalRequestException + * @throws RequestException + * @throws JsonException */ - public function execute(string $guildId, bool $dryRun = false, bool $includeExpiring = false): array + public function execute(string $guildId, bool $dryRun = false, bool $includeExpiring = false): PurgeInvitesResultDTO { $response = $this->connector->send(new ListGuildInvites($guildId)); @@ -40,42 +50,24 @@ public function execute(string $guildId, bool $dryRun = false, bool $includeExpi ); $matches = array_values(array_map( - static fn (array $invite): array => [ - 'code' => $invite['code'], - 'channel' => $invite['channel']['name'] ?? 'unknown', - 'inviter' => $invite['inviter']['username'] ?? 'unknown', - 'created_at' => isset($invite['created_at']) - ? Date::parse($invite['created_at'])->timezone(config('app.display_timezone'))->format('d/m/Y H:i') - : '', - ], + MatchedInviteDTO::fromDiscordApi(...), $unused, )); if ($dryRun) { - return [ - 'total' => count($allInvites), - 'matched' => count($unused), - 'deleted' => 0, - 'failed' => 0, - 'invites' => $matches, - ]; + return PurgeInvitesResultDTO::fromDryRun(total: count($allInvites), invites: $matches); } $deleted = 0; $failed = 0; - foreach ($unused as $index => $invite) { + foreach (array_values($unused) as $index => $invite) { if ($index > 0) { - Sleep::usleep(random_int(200_000, 500_000)); + $this->jitteredSleep(); } try { - $response = $this->connector->send(new DeleteInvite($invite['code'])); - - if ($response->failed()) { - throw new RuntimeException(sprintf('HTTP %d: %s', $response->status(), $response->body())); - } - + $this->deleteInvite($invite['code']); $deleted++; } catch (Throwable $e) { $failed++; @@ -86,12 +78,47 @@ public function execute(string $guildId, bool $dryRun = false, bool $includeExpi } } - return [ - 'total' => count($allInvites), - 'matched' => count($unused), - 'deleted' => $deleted, - 'failed' => $failed, - 'invites' => $matches, - ]; + return PurgeInvitesResultDTO::fromPurge( + total: count($allInvites), + invites: $matches, + deleted: $deleted, + failed: $failed, + ); + } + + /** + * @throws RandomException + */ + private function jitteredSleep(float $baseSeconds = 0.0): void + { + $jitter = random_int(3_000, 6_000) / 1_000; + + Sleep::for($baseSeconds + $jitter)->seconds(); + } + + /** + * @throws RandomException + * @throws FatalRequestException + * @throws RequestException + * @throws JsonException + */ + private function deleteInvite(string $code): void + { + for ($attempt = 0; $attempt <= self::MAX_RETRIES; $attempt++) { + $response = $this->connector->send(new DeleteInvite($code)); + + if ($response->successful()) { + return; + } + + if ($response->status() === 429 && $attempt < self::MAX_RETRIES) { + $retryAfter = (float) ($response->json('retry_after') ?? 1.0); + $this->jitteredSleep($retryAfter); + + continue; + } + + throw new RuntimeException(sprintf('HTTP %d: %s', $response->status(), $response->body())); + } } } diff --git a/app-modules/integration-discord/src/Sync/Console/PurgeUnusedInvitesCommand.php b/app-modules/integration-discord/src/Sync/Console/PurgeUnusedInvitesCommand.php index 76c8278c..88b25f93 100644 --- a/app-modules/integration-discord/src/Sync/Console/PurgeUnusedInvitesCommand.php +++ b/app-modules/integration-discord/src/Sync/Console/PurgeUnusedInvitesCommand.php @@ -5,14 +5,25 @@ namespace He4rt\IntegrationDiscord\Sync\Console; use He4rt\IntegrationDiscord\Sync\Actions\PurgeUnusedInvitesAction; +use He4rt\IntegrationDiscord\Sync\DTOs\MatchedInviteDTO; use Illuminate\Console\Attributes\Description; use Illuminate\Console\Attributes\Signature; use Illuminate\Console\Command; +use JsonException; +use Random\RandomException; +use Saloon\Exceptions\Request\FatalRequestException; +use Saloon\Exceptions\Request\RequestException; #[Description('Purge unused infinite Discord guild invites (max_age=0, uses=0)')] #[Signature('discord:purge-invites {guild_id?} {--dry-run : List invites without deleting} {--include-expiring : Also purge unused invites that have an expiration time}')] final class PurgeUnusedInvitesCommand extends Command { + /** + * @throws RandomException + * @throws FatalRequestException + * @throws RequestException + * @throws JsonException + */ public function handle(PurgeUnusedInvitesAction $action): int { $guildId = $this->argument('guild_id') ?? config('he4rt.discord.guild_id'); @@ -37,7 +48,7 @@ public function handle(PurgeUnusedInvitesAction $action): int $result = $action->execute((string) $guildId, $dryRun, $includeExpiring); - if ($result['matched'] === 0) { + if ($result->matched === 0) { $this->info(sprintf('No %s invites found. Nothing to do.', $scope)); return self::SUCCESS; @@ -46,22 +57,22 @@ public function handle(PurgeUnusedInvitesAction $action): int $this->table( ['Code', 'Inviter', 'Channel', 'Created At'], array_map( - static fn (array $invite): array => [ - $invite['code'], - $invite['inviter'], - $invite['channel'], - $invite['created_at'], + static fn (MatchedInviteDTO $invite): array => [ + $invite->code, + $invite->inviter, + $invite->channel, + $invite->createdAt, ], - $result['invites'], + $result->invites, ), ); $this->newLine(); $this->info(sprintf( 'Found %d %s invite(s) out of %d total.', - $result['matched'], + $result->matched, $scope, - $result['total'], + $result->total, )); if ($dryRun) { @@ -70,10 +81,10 @@ public function handle(PurgeUnusedInvitesAction $action): int return self::SUCCESS; } - $this->info(sprintf('Deleted %d invite(s).', $result['deleted'])); + $this->info(sprintf('Deleted %d invite(s).', $result->deleted)); - if ($result['failed'] > 0) { - $this->warn(sprintf('%d invite(s) failed to delete. Check logs for details.', $result['failed'])); + if ($result->failed > 0) { + $this->warn(sprintf('%d invite(s) failed to delete. Check logs for details.', $result->failed)); return self::FAILURE; } diff --git a/app-modules/integration-discord/src/Sync/DTOs/MatchedInviteDTO.php b/app-modules/integration-discord/src/Sync/DTOs/MatchedInviteDTO.php new file mode 100644 index 00000000..be7ee336 --- /dev/null +++ b/app-modules/integration-discord/src/Sync/DTOs/MatchedInviteDTO.php @@ -0,0 +1,32 @@ + $invite + */ + public static function fromDiscordApi(array $invite): self + { + return new self( + code: $invite['code'], + channel: $invite['channel']['name'] ?? 'unknown', + inviter: $invite['inviter']['username'] ?? 'unknown', + createdAt: isset($invite['created_at']) + ? Date::parse($invite['created_at'])->timezone(config('app.display_timezone'))->format('d/m/Y H:i') + : '', + ); + } +} diff --git a/app-modules/integration-discord/src/Sync/DTOs/PurgeInvitesResultDTO.php b/app-modules/integration-discord/src/Sync/DTOs/PurgeInvitesResultDTO.php new file mode 100644 index 00000000..58a6d072 --- /dev/null +++ b/app-modules/integration-discord/src/Sync/DTOs/PurgeInvitesResultDTO.php @@ -0,0 +1,47 @@ + $invites + */ + public function __construct( + public int $total, + public int $matched, + public int $deleted, + public int $failed, + public array $invites, + ) {} + + /** + * @param list $invites + */ + public static function fromDryRun(int $total, array $invites): self + { + return new self( + total: $total, + matched: count($invites), + deleted: 0, + failed: 0, + invites: $invites, + ); + } + + /** + * @param list $invites + */ + public static function fromPurge(int $total, array $invites, int $deleted, int $failed): self + { + return new self( + total: $total, + matched: count($invites), + deleted: $deleted, + failed: $failed, + invites: $invites, + ); + } +} diff --git a/app-modules/integration-discord/tests/Unit/Sync/PurgeUnusedInvitesActionTest.php b/app-modules/integration-discord/tests/Unit/Sync/PurgeUnusedInvitesActionTest.php index f6c4d5c2..fc6d4dd1 100644 --- a/app-modules/integration-discord/tests/Unit/Sync/PurgeUnusedInvitesActionTest.php +++ b/app-modules/integration-discord/tests/Unit/Sync/PurgeUnusedInvitesActionTest.php @@ -6,6 +6,7 @@ use He4rt\IntegrationDiscord\Transport\DiscordConnector; use He4rt\IntegrationDiscord\Transport\Requests\Invites\DeleteInvite; use He4rt\IntegrationDiscord\Transport\Requests\Invites\ListGuildInvites; +use Illuminate\Support\Sleep; use Saloon\Http\Faking\MockClient; use Saloon\Http\Faking\MockResponse; @@ -40,15 +41,15 @@ function makeInvite(string $code, int $maxAge, int $uses, string $channel = 'gen $action = new PurgeUnusedInvitesAction($connector); $result = $action->execute('guild-123', dryRun: true); - expect($result['total'])->toBe(4) - ->and($result['matched'])->toBe(2) - ->and($result['deleted'])->toBe(0) - ->and($result['failed'])->toBe(0) - ->and($result['invites'])->toHaveCount(2) - ->and($result['invites'][0]['code'])->toBe('aaa') - ->and($result['invites'][1]['code'])->toBe('ddd') - ->and($result['invites'][1]['channel'])->toBe('dev-chat') - ->and($result['invites'][1]['inviter'])->toBe('bob'); + expect($result->total)->toBe(4) + ->and($result->matched)->toBe(2) + ->and($result->deleted)->toBe(0) + ->and($result->failed)->toBe(0) + ->and($result->invites)->toHaveCount(2) + ->and($result->invites[0]->code)->toBe('aaa') + ->and($result->invites[1]->code)->toBe('ddd') + ->and($result->invites[1]->channel)->toBe('dev-chat') + ->and($result->invites[1]->inviter)->toBe('bob'); $mockClient->assertNotSent(DeleteInvite::class); }); @@ -71,10 +72,10 @@ function makeInvite(string $code, int $maxAge, int $uses, string $channel = 'gen $action = new PurgeUnusedInvitesAction($connector); $result = $action->execute('guild-123', dryRun: false); - expect($result['total'])->toBe(3) - ->and($result['matched'])->toBe(2) - ->and($result['deleted'])->toBe(2) - ->and($result['failed'])->toBe(0); + expect($result->total)->toBe(3) + ->and($result->matched)->toBe(2) + ->and($result->deleted)->toBe(2) + ->and($result->failed)->toBe(0); $mockClient->assertSentCount(3); }); @@ -96,10 +97,10 @@ function makeInvite(string $code, int $maxAge, int $uses, string $channel = 'gen $action = new PurgeUnusedInvitesAction($connector); $result = $action->execute('guild-123', dryRun: false); - expect($result['total'])->toBe(3) - ->and($result['matched'])->toBe(0) - ->and($result['deleted'])->toBe(0) - ->and($result['invites'])->toBeEmpty(); + expect($result->total)->toBe(3) + ->and($result->matched)->toBe(0) + ->and($result->deleted)->toBe(0) + ->and($result->invites)->toBeEmpty(); }); it('counts failures individually without aborting', function (): void { @@ -122,9 +123,9 @@ function makeInvite(string $code, int $maxAge, int $uses, string $channel = 'gen $action = new PurgeUnusedInvitesAction($connector); $result = $action->execute('guild-123', dryRun: false); - expect($result['matched'])->toBe(3) - ->and($result['deleted'])->toBe(2) - ->and($result['failed'])->toBe(1); + expect($result->matched)->toBe(3) + ->and($result->deleted)->toBe(2) + ->and($result->failed)->toBe(1); }); it('includes expiring invites when flag is set', function (): void { @@ -146,11 +147,11 @@ function makeInvite(string $code, int $maxAge, int $uses, string $channel = 'gen $action = new PurgeUnusedInvitesAction($connector); $result = $action->execute('guild-123', dryRun: true, includeExpiring: true); - expect($result['total'])->toBe(5) - ->and($result['matched'])->toBe(3) - ->and($result['invites'][0]['code'])->toBe('aaa') - ->and($result['invites'][1]['code'])->toBe('bbb') - ->and($result['invites'][2]['code'])->toBe('ccc'); + expect($result->total)->toBe(5) + ->and($result->matched)->toBe(3) + ->and($result->invites[0]->code)->toBe('aaa') + ->and($result->invites[1]->code)->toBe('bbb') + ->and($result->invites[2]->code)->toBe('ccc'); $mockClient->assertNotSent(DeleteInvite::class); }); @@ -167,6 +168,56 @@ function makeInvite(string $code, int $maxAge, int $uses, string $channel = 'gen $action->execute('guild-123'); })->throws(RuntimeException::class, 'Failed to list guild invites: HTTP 403'); +it('retries on 429 rate limit and succeeds', function (): void { + Sleep::fake(); + + $invites = [ + makeInvite('aaa', maxAge: 0, uses: 0), + ]; + + $mockClient = new MockClient([ + MockResponse::make($invites), + MockResponse::make(['message' => 'You are being rate limited.', 'retry_after' => 0.3], 429), + 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); + + Sleep::assertSleptTimes(1); +}); + +it('fails after exhausting retries on persistent 429', function (): void { + Sleep::fake(); + + $invites = [ + makeInvite('aaa', maxAge: 0, uses: 0), + ]; + + $mockClient = new MockClient([ + MockResponse::make($invites), + MockResponse::make(['message' => 'You are being rate limited.', 'retry_after' => 0.3], 429), + MockResponse::make(['message' => 'You are being rate limited.', 'retry_after' => 0.3], 429), + MockResponse::make(['message' => 'You are being rate limited.', 'retry_after' => 0.3], 429), + MockResponse::make(['message' => 'You are being rate limited.', 'retry_after' => 0.3], 429), + ]); + + $connector = new DiscordConnector('test-token'); + $connector->withMockClient($mockClient); + + $action = new PurgeUnusedInvitesAction($connector); + $result = $action->execute('guild-123', dryRun: false); + + expect($result->deleted)->toBe(0) + ->and($result->failed)->toBe(1); +}); + it('handles an empty invite list', function (): void { $mockClient = new MockClient([ ListGuildInvites::class => MockResponse::make([]), @@ -178,7 +229,7 @@ function makeInvite(string $code, int $maxAge, int $uses, string $channel = 'gen $action = new PurgeUnusedInvitesAction($connector); $result = $action->execute('guild-123'); - expect($result['total'])->toBe(0) - ->and($result['matched'])->toBe(0) - ->and($result['invites'])->toBeEmpty(); + expect($result->total)->toBe(0) + ->and($result->matched)->toBe(0) + ->and($result->invites)->toBeEmpty(); });