Give Instagram/Threads a wider proactive-refresh window

Extension-model tokens (Instagram/Threads) can't be refreshed once they
expire, so the shared 30-minute cron window left only a ~15-minute buffer
against queue backlog on the default queue — and a lapse forces a full
reconnect. Rotating platforms go through verify() and won't rotate a
still-valid token, so they keep the tight 30-minute window; extension
platforms now get a 24-hour lead via a per-model query.
This commit is contained in:
Paulo Castellano 2026-07-03 10:33:26 -03:00
parent 6037169aa9
commit 94d0156ec0
3 changed files with 104 additions and 16 deletions

View file

@ -4,17 +4,25 @@
namespace App\Console\Commands;
use App\Enums\SocialAccount\Platform;
use App\Enums\SocialAccount\Status;
use App\Jobs\RefreshSocialToken;
use App\Models\SocialAccount;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
class RefreshExpiringTokens extends Command
{
protected $signature = 'social:refresh-expiring-tokens';
protected $description = 'Proactively refresh tokens expiring in the next 30 minutes (or already expired)';
protected $description = 'Proactively refresh social tokens before they expire';
/**
* Rotating refresh_token platforms only need a short lead: verify() won't
* rotate a still-valid token, so we catch them right before or after expiry.
* Extension-model platforms (Instagram/Threads) can't be refreshed once
* expired, so they get a much wider lead to survive queue backlog.
*/
public function handle(): void
{
$count = 0;
@ -22,7 +30,15 @@ public function handle(): void
SocialAccount::query()
->where('status', Status::Connected)
->whereNotNull('token_expires_at')
->where('token_expires_at', '<=', now()->addMinutes(30))
->where(function (Builder $query) {
$query->where(function (Builder $extension) {
$extension->whereIn('platform', Platform::extensionModelValues())
->where('token_expires_at', '<=', now()->addDay());
})->orWhere(function (Builder $rotating) {
$rotating->whereNotIn('platform', Platform::extensionModelValues())
->where('token_expires_at', '<=', now()->addMinutes(30));
});
})
->chunk(50, function ($accounts) use (&$count) {
foreach ($accounts as $account) {
RefreshSocialToken::dispatch($account);

View file

@ -277,6 +277,22 @@ public function extendsAccessTokenOnRefresh(): bool
};
}
/**
* All platform values that refresh by extending the access_token itself
* (see extendsAccessTokenOnRefresh). Because these can't be refreshed once
* expired, the proactive-refresh cron gives them a wider window than
* rotating platforms so a queue backlog can't let them lapse.
*
* @return array<int, string>
*/
public static function extensionModelValues(): array
{
return array_values(array_map(
fn (self $platform): string => $platform->value,
array_filter(self::cases(), fn (self $platform): bool => $platform->extendsAccessTokenOnRefresh()),
));
}
public function queue(): string
{
return 'social-'.$this->value;

View file

@ -9,48 +9,73 @@
use App\Models\Workspace;
use Illuminate\Support\Facades\Queue;
test('it dispatches refresh jobs for tokens expiring within 30 minutes or already expired', function () {
test('it dispatches refresh jobs for rotating tokens near expiry and extension tokens well ahead of expiry', function () {
Queue::fake();
$workspace = Workspace::factory()->create();
// Should be refreshed (expires in 15 minutes — inside the proactive window)
$expiringSoon = SocialAccount::factory()->create([
// Rotating platform expiring in 15 minutes — inside the 30-minute window.
$rotatingSoon = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::LinkedIn,
'status' => Status::Connected,
'token_expires_at' => now()->addMinutes(15),
]);
// Should NOT be refreshed (expires in 1 hour — outside the proactive window)
// Rotating platform expiring in 1 hour — OUTSIDE the 30-minute window.
SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::X,
'status' => Status::Connected,
'token_expires_at' => now()->addHour(),
]);
// Extension platform expiring in 1 hour — inside the wide 24-hour window.
// (On the old shared 30-minute window this lapsed under queue backlog.)
$extensionSoon = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::Instagram,
'status' => Status::Connected,
'token_expires_at' => now()->addHour(),
]);
// SHOULD be refreshed (already expired — last-chance attempt before the
// refresh_token also dies at the provider).
$justExpired = SocialAccount::factory()->create([
// Extension platform expiring in 12 hours — still inside the 24-hour window.
$extensionLater = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::Threads,
'status' => Status::Connected,
'token_expires_at' => now()->addHours(12),
]);
// Extension platform expiring in 2 days — OUTSIDE the 24-hour window.
SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::Instagram,
'status' => Status::Connected,
'token_expires_at' => now()->addDays(2),
]);
// Rotating platform already expired — last-chance attempt before the
// refresh_token also dies at the provider.
$rotatingExpired = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::TikTok,
'status' => Status::Connected,
'token_expires_at' => now()->subHour(),
]);
// Should NOT be refreshed (disconnected)
// Disconnected — never refreshed.
SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::X,
'platform' => Platform::Pinterest,
'status' => Status::Disconnected,
'token_expires_at' => now()->addHour(),
]);
// Should NOT be refreshed (already token expired — daily verify handles these)
// Already token expired — daily verify handles these.
SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::Pinterest,
'platform' => Platform::YouTube,
'status' => Status::TokenExpired,
'token_expires_at' => now()->subHour(),
]);
@ -58,9 +83,40 @@
$this->artisan('social:refresh-expiring-tokens')
->assertSuccessful();
Queue::assertPushed(RefreshSocialToken::class, 2);
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $expiringSoon->id);
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $justExpired->id);
Queue::assertPushed(RefreshSocialToken::class, 4);
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $rotatingSoon->id);
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $extensionSoon->id);
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $extensionLater->id);
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $rotatingExpired->id);
});
test('extension-model platforms get a wider refresh window than rotating platforms', function () {
Queue::fake();
$workspace = Workspace::factory()->create();
// Same expiry (1 hour out) for both — only the extension-model account
// should be dispatched, because it can't be refreshed once expired.
$extension = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::Instagram,
'status' => Status::Connected,
'token_expires_at' => now()->addHour(),
]);
$rotating = SocialAccount::factory()->create([
'workspace_id' => $workspace->id,
'platform' => Platform::X,
'status' => Status::Connected,
'token_expires_at' => now()->addHour(),
]);
$this->artisan('social:refresh-expiring-tokens')
->assertSuccessful();
Queue::assertPushed(RefreshSocialToken::class, 1);
Queue::assertPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $extension->id);
Queue::assertNotPushed(RefreshSocialToken::class, fn ($job) => $job->account->id === $rotating->id);
});
test('it dispatches nothing when no tokens are expiring', function () {