Skip to content

Commit f173c39

Browse files
authored
fix(integration-discord): handle 429 rate limits (#334)
## Summary - Add retry logic with jittered backoff (3-6s) to `purge-invites` command, respecting Discord's `retry_after` header on 429 responses - Increase base delay between delete requests from 200-500ms to 3-6s with jitter to prevent Cloudflare IP-level blocking (error 1015) - Extract `PurgeInvitesResultDTO` and `MatchedInviteDTO` from raw array returns, with `fromDiscordApi()`, `fromDryRun()`, and `fromPurge()` factory methods ## Test plan - [x] Existing 7 tests updated and passing - [x] New test: retries on 429 and succeeds after backoff - [x] New test: fails gracefully after exhausting 3 retries - [x] PHPStan, Pint, Rector all clean
1 parent 54cd9e3 commit f173c39

5 files changed

Lines changed: 241 additions & 73 deletions

File tree

app-modules/integration-discord/src/Sync/Actions/PurgeUnusedInvitesAction.php

Lines changed: 60 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,35 @@
44

55
namespace He4rt\IntegrationDiscord\Sync\Actions;
66

7+
use He4rt\IntegrationDiscord\Sync\DTOs\MatchedInviteDTO;
8+
use He4rt\IntegrationDiscord\Sync\DTOs\PurgeInvitesResultDTO;
79
use He4rt\IntegrationDiscord\Transport\DiscordConnector;
810
use He4rt\IntegrationDiscord\Transport\Requests\Invites\DeleteInvite;
911
use He4rt\IntegrationDiscord\Transport\Requests\Invites\ListGuildInvites;
10-
use Illuminate\Support\Facades\Date;
1112
use Illuminate\Support\Facades\Log;
1213
use Illuminate\Support\Sleep;
14+
use JsonException;
15+
use Random\RandomException;
1316
use RuntimeException;
17+
use Saloon\Exceptions\Request\FatalRequestException;
18+
use Saloon\Exceptions\Request\RequestException;
1419
use Throwable;
1520

1621
final readonly class PurgeUnusedInvitesAction
1722
{
23+
private const int MAX_RETRIES = 3;
24+
1825
public function __construct(
1926
private DiscordConnector $connector,
2027
) {}
2128

2229
/**
23-
* @return array{total: int, matched: int, deleted: int, failed: int, invites: list<array{code: string, channel: string, inviter: string, created_at: string}>}
30+
* @throws RandomException
31+
* @throws FatalRequestException
32+
* @throws RequestException
33+
* @throws JsonException
2434
*/
25-
public function execute(string $guildId, bool $dryRun = false, bool $includeExpiring = false): array
35+
public function execute(string $guildId, bool $dryRun = false, bool $includeExpiring = false): PurgeInvitesResultDTO
2636
{
2737
$response = $this->connector->send(new ListGuildInvites($guildId));
2838

@@ -40,42 +50,24 @@ public function execute(string $guildId, bool $dryRun = false, bool $includeExpi
4050
);
4151

4252
$matches = array_values(array_map(
43-
static fn (array $invite): array => [
44-
'code' => $invite['code'],
45-
'channel' => $invite['channel']['name'] ?? 'unknown',
46-
'inviter' => $invite['inviter']['username'] ?? 'unknown',
47-
'created_at' => isset($invite['created_at'])
48-
? Date::parse($invite['created_at'])->timezone(config('app.display_timezone'))->format('d/m/Y H:i')
49-
: '',
50-
],
53+
MatchedInviteDTO::fromDiscordApi(...),
5154
$unused,
5255
));
5356

5457
if ($dryRun) {
55-
return [
56-
'total' => count($allInvites),
57-
'matched' => count($unused),
58-
'deleted' => 0,
59-
'failed' => 0,
60-
'invites' => $matches,
61-
];
58+
return PurgeInvitesResultDTO::fromDryRun(total: count($allInvites), invites: $matches);
6259
}
6360

6461
$deleted = 0;
6562
$failed = 0;
6663

67-
foreach ($unused as $index => $invite) {
64+
foreach (array_values($unused) as $index => $invite) {
6865
if ($index > 0) {
69-
Sleep::usleep(random_int(200_000, 500_000));
66+
$this->jitteredSleep();
7067
}
7168

7269
try {
73-
$response = $this->connector->send(new DeleteInvite($invite['code']));
74-
75-
if ($response->failed()) {
76-
throw new RuntimeException(sprintf('HTTP %d: %s', $response->status(), $response->body()));
77-
}
78-
70+
$this->deleteInvite($invite['code']);
7971
$deleted++;
8072
} catch (Throwable $e) {
8173
$failed++;
@@ -86,12 +78,47 @@ public function execute(string $guildId, bool $dryRun = false, bool $includeExpi
8678
}
8779
}
8880

89-
return [
90-
'total' => count($allInvites),
91-
'matched' => count($unused),
92-
'deleted' => $deleted,
93-
'failed' => $failed,
94-
'invites' => $matches,
95-
];
81+
return PurgeInvitesResultDTO::fromPurge(
82+
total: count($allInvites),
83+
invites: $matches,
84+
deleted: $deleted,
85+
failed: $failed,
86+
);
87+
}
88+
89+
/**
90+
* @throws RandomException
91+
*/
92+
private function jitteredSleep(float $baseSeconds = 0.0): void
93+
{
94+
$jitter = random_int(3_000, 6_000) / 1_000;
95+
96+
Sleep::for($baseSeconds + $jitter)->seconds();
97+
}
98+
99+
/**
100+
* @throws RandomException
101+
* @throws FatalRequestException
102+
* @throws RequestException
103+
* @throws JsonException
104+
*/
105+
private function deleteInvite(string $code): void
106+
{
107+
for ($attempt = 0; $attempt <= self::MAX_RETRIES; $attempt++) {
108+
$response = $this->connector->send(new DeleteInvite($code));
109+
110+
if ($response->successful()) {
111+
return;
112+
}
113+
114+
if ($response->status() === 429 && $attempt < self::MAX_RETRIES) {
115+
$retryAfter = (float) ($response->json('retry_after') ?? 1.0);
116+
$this->jitteredSleep($retryAfter);
117+
118+
continue;
119+
}
120+
121+
throw new RuntimeException(sprintf('HTTP %d: %s', $response->status(), $response->body()));
122+
}
96123
}
97124
}

app-modules/integration-discord/src/Sync/Console/PurgeUnusedInvitesCommand.php

Lines changed: 23 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,25 @@
55
namespace He4rt\IntegrationDiscord\Sync\Console;
66

77
use He4rt\IntegrationDiscord\Sync\Actions\PurgeUnusedInvitesAction;
8+
use He4rt\IntegrationDiscord\Sync\DTOs\MatchedInviteDTO;
89
use Illuminate\Console\Attributes\Description;
910
use Illuminate\Console\Attributes\Signature;
1011
use Illuminate\Console\Command;
12+
use JsonException;
13+
use Random\RandomException;
14+
use Saloon\Exceptions\Request\FatalRequestException;
15+
use Saloon\Exceptions\Request\RequestException;
1116

1217
#[Description('Purge unused infinite Discord guild invites (max_age=0, uses=0)')]
1318
#[Signature('discord:purge-invites {guild_id?} {--dry-run : List invites without deleting} {--include-expiring : Also purge unused invites that have an expiration time}')]
1419
final class PurgeUnusedInvitesCommand extends Command
1520
{
21+
/**
22+
* @throws RandomException
23+
* @throws FatalRequestException
24+
* @throws RequestException
25+
* @throws JsonException
26+
*/
1627
public function handle(PurgeUnusedInvitesAction $action): int
1728
{
1829
$guildId = $this->argument('guild_id') ?? config('he4rt.discord.guild_id');
@@ -37,7 +48,7 @@ public function handle(PurgeUnusedInvitesAction $action): int
3748

3849
$result = $action->execute((string) $guildId, $dryRun, $includeExpiring);
3950

40-
if ($result['matched'] === 0) {
51+
if ($result->matched === 0) {
4152
$this->info(sprintf('No %s invites found. Nothing to do.', $scope));
4253

4354
return self::SUCCESS;
@@ -46,22 +57,22 @@ public function handle(PurgeUnusedInvitesAction $action): int
4657
$this->table(
4758
['Code', 'Inviter', 'Channel', 'Created At'],
4859
array_map(
49-
static fn (array $invite): array => [
50-
$invite['code'],
51-
$invite['inviter'],
52-
$invite['channel'],
53-
$invite['created_at'],
60+
static fn (MatchedInviteDTO $invite): array => [
61+
$invite->code,
62+
$invite->inviter,
63+
$invite->channel,
64+
$invite->createdAt,
5465
],
55-
$result['invites'],
66+
$result->invites,
5667
),
5768
);
5869

5970
$this->newLine();
6071
$this->info(sprintf(
6172
'Found %d %s invite(s) out of %d total.',
62-
$result['matched'],
73+
$result->matched,
6374
$scope,
64-
$result['total'],
75+
$result->total,
6576
));
6677

6778
if ($dryRun) {
@@ -70,10 +81,10 @@ public function handle(PurgeUnusedInvitesAction $action): int
7081
return self::SUCCESS;
7182
}
7283

73-
$this->info(sprintf('Deleted %d invite(s).', $result['deleted']));
84+
$this->info(sprintf('Deleted %d invite(s).', $result->deleted));
7485

75-
if ($result['failed'] > 0) {
76-
$this->warn(sprintf('%d invite(s) failed to delete. Check logs for details.', $result['failed']));
86+
if ($result->failed > 0) {
87+
$this->warn(sprintf('%d invite(s) failed to delete. Check logs for details.', $result->failed));
7788

7889
return self::FAILURE;
7990
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace He4rt\IntegrationDiscord\Sync\DTOs;
6+
7+
use Illuminate\Support\Facades\Date;
8+
9+
final readonly class MatchedInviteDTO
10+
{
11+
public function __construct(
12+
public string $code,
13+
public string $channel,
14+
public string $inviter,
15+
public string $createdAt,
16+
) {}
17+
18+
/**
19+
* @param array<string, mixed> $invite
20+
*/
21+
public static function fromDiscordApi(array $invite): self
22+
{
23+
return new self(
24+
code: $invite['code'],
25+
channel: $invite['channel']['name'] ?? 'unknown',
26+
inviter: $invite['inviter']['username'] ?? 'unknown',
27+
createdAt: isset($invite['created_at'])
28+
? Date::parse($invite['created_at'])->timezone(config('app.display_timezone'))->format('d/m/Y H:i')
29+
: '',
30+
);
31+
}
32+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace He4rt\IntegrationDiscord\Sync\DTOs;
6+
7+
final readonly class PurgeInvitesResultDTO
8+
{
9+
/**
10+
* @param list<MatchedInviteDTO> $invites
11+
*/
12+
public function __construct(
13+
public int $total,
14+
public int $matched,
15+
public int $deleted,
16+
public int $failed,
17+
public array $invites,
18+
) {}
19+
20+
/**
21+
* @param list<MatchedInviteDTO> $invites
22+
*/
23+
public static function fromDryRun(int $total, array $invites): self
24+
{
25+
return new self(
26+
total: $total,
27+
matched: count($invites),
28+
deleted: 0,
29+
failed: 0,
30+
invites: $invites,
31+
);
32+
}
33+
34+
/**
35+
* @param list<MatchedInviteDTO> $invites
36+
*/
37+
public static function fromPurge(int $total, array $invites, int $deleted, int $failed): self
38+
{
39+
return new self(
40+
total: $total,
41+
matched: count($invites),
42+
deleted: $deleted,
43+
failed: $failed,
44+
invites: $invites,
45+
);
46+
}
47+
}

0 commit comments

Comments
 (0)