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:
parent
6037169aa9
commit
94d0156ec0
3 changed files with 104 additions and 16 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue