Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c0d0d57
Stop paying X for token checks the refresh already proves
paulocastellano Aug 20, 2026
9775578
Don't disconnect an account whose access token still works
paulocastellano Aug 20, 2026
4d2795a
Only record a verification something actually proved
paulocastellano Aug 20, 2026
48e4195
Fall back to the token a concurrent refresh persisted
paulocastellano Aug 20, 2026
8a3cfe8
Don't record a verification when the lock skipped the refresh
paulocastellano Aug 20, 2026
5001c9a
Close the two concurrency gaps the refresh path leaves open
paulocastellano Aug 20, 2026
ec1ec66
Correct which providers actually single-use their refresh_token
paulocastellano Aug 20, 2026
ec3fe17
Read X post metrics from the timeline that already returned them
paulocastellano Aug 20, 2026
280407d
Cover the analytics paths a happy-path test walks straight past
paulocastellano Aug 20, 2026
af379af
Close five issues an independent review found in this branch
paulocastellano Aug 20, 2026
fb22c41
Guard the match that no longer has a default arm
paulocastellano Aug 20, 2026
609a56e
Make the per-platform guard fail on a broken client chain
paulocastellano Aug 20, 2026
7a7d964
Stop trading a recoverable failure for an unrecoverable one
paulocastellano Aug 20, 2026
f5d7d1f
Cover the tokenless-200 guard on every platform, not just X
paulocastellano Aug 20, 2026
d87d76d
Stop the fallback from reading every failure as good news
paulocastellano Aug 20, 2026
04f6d84
Stop a bad hour at the provider from disconnecting anyone
paulocastellano Aug 20, 2026
1195f34
Keep a lock collision off the analytics page
paulocastellano Aug 20, 2026
20d2aba
Degrade analytics on an unreachable platform, not on a bug
paulocastellano Aug 20, 2026
e395d2e
Trim the commentary back to what the code cannot say
paulocastellano Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions app/Console/Commands/RefreshExpiringTokens.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,9 @@ class RefreshExpiringTokens extends Command
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.
* Rotating platforms get a short lead — a wider window would only rotate
* more often. Instagram and Threads can't be refreshed once expired, so
* theirs is wide enough to survive queue backlog.
*/
public function handle(): void
{
Expand All @@ -46,6 +45,8 @@ public function handle(): void
}
});

$this->info("Dispatched {$count} token refresh jobs.");
// Accounts in the window, not jobs queued: the job is unique per
// account, so a dispatch during a backlog is silently discarded.
$this->info("{$count} accounts due for a token refresh.");
}
}
44 changes: 32 additions & 12 deletions app/Http/Controllers/App/AnalyticsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
namespace App\Http\Controllers\App;

use App\Enums\SocialAccount\Platform;
use App\Exceptions\PlatformUnavailableException;
use App\Http\Controllers\Controller;
use App\Models\SocialAccount;
use App\Services\Social\FacebookAnalytics;
Expand All @@ -16,6 +17,7 @@
use App\Services\Social\TikTokAnalytics;
use App\Services\Social\XAnalytics;
use App\Services\Social\YouTubeAnalytics;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Carbon;
Expand Down Expand Up @@ -72,19 +74,37 @@ public function show(Request $request, SocialAccount $account): JsonResponse
$since = $request->has('since') ? Carbon::parse($request->input('since')) : null;
$until = $request->has('until') ? Carbon::parse($request->input('until')) : null;

$metrics = match ($account->platform) {
Platform::TikTok => app(TikTokAnalytics::class)->getMetrics($account),
Platform::Instagram, Platform::InstagramFacebook => app(InstagramAnalytics::class)->getMetrics($account, $since, $until),
Platform::Threads => app(ThreadsAnalytics::class)->getMetrics($account, $since, $until),
Platform::Facebook => app(FacebookAnalytics::class)->getMetrics($account, $since, $until),
Platform::X => app(XAnalytics::class)->getMetrics($account, $since, $until),
Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->getMetrics($account, $since, $until),
Platform::Pinterest => app(PinterestAnalytics::class)->getMetrics($account, $since, $until),
Platform::YouTube => app(YouTubeAnalytics::class)->getMetrics($account, $since, $until),
Platform::Telegram => app(TelegramAnalytics::class)->getMetrics($account),
default => [],
};
$metrics = $this->metricsFor($account, $since, $until);

return response()->json(['metrics' => $metrics]);
}

/**
* An unreachable platform is not a server error — empty numbers beat a 500
* on a page the user just opened. Narrow on purpose: catching Throwable
* would render a defect as "this account has no activity".
*
* @return array<int, array{label: string, value: int|string}>
*/
private function metricsFor(SocialAccount $account, ?Carbon $since, ?Carbon $until): array
{
try {
return match ($account->platform) {
Platform::TikTok => app(TikTokAnalytics::class)->getMetrics($account),
Platform::Instagram, Platform::InstagramFacebook => app(InstagramAnalytics::class)->getMetrics($account, $since, $until),
Platform::Threads => app(ThreadsAnalytics::class)->getMetrics($account, $since, $until),
Platform::Facebook => app(FacebookAnalytics::class)->getMetrics($account, $since, $until),
Platform::X => app(XAnalytics::class)->getMetrics($account, $since, $until),
Platform::LinkedInPage => app(LinkedInPageAnalytics::class)->getMetrics($account, $since, $until),
Platform::Pinterest => app(PinterestAnalytics::class)->getMetrics($account, $since, $until),
Platform::YouTube => app(YouTubeAnalytics::class)->getMetrics($account, $since, $until),
Platform::Telegram => app(TelegramAnalytics::class)->getMetrics($account),
default => [],
};
} catch (PlatformUnavailableException|ConnectionException $e) {
report($e);

return [];
}
}
}
81 changes: 73 additions & 8 deletions app/Jobs/RefreshSocialToken.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,41 @@
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\ConnectionVerifier;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Queue\Queueable;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Log;
use Throwable;

class RefreshSocialToken implements ShouldQueue
class RefreshSocialToken implements ShouldBeUnique, ShouldQueue
{
use Queueable;

public int $tries = 1;

// token_expires_at only moves once this job runs, so a backlogged queue
// would stack one job per tick for the same account.
public int $uniqueFor = 900;

public function __construct(public SocialAccount $account) {}

public function uniqueId(): string
{
return $this->account->id;
}

/**
* Refresh outright rather than verifying first: a refresh replaces the
* access token, leaving nothing for a verify call — billed as a "User:
* Read" on X — to confirm.
*/
public function handle(ConnectionVerifier $verifier): void
{
try {
if ($this->account->platform->extendsAccessTokenOnRefresh()) {
// Instagram/Threads extend the long-lived token itself and
// can't be refreshed once expired, so extend it while it's
// still valid instead of waiting for it to fail.
$verifier->refreshToken($this->account);
} else {
$verifier->verify($this->account);
if ($verifier->refreshToken($this->account)) {
$this->recordVerification();
}
} catch (PlatformUnavailableException $e) {
Log::warning('Token refresh skipped: platform unavailable', [
Expand All @@ -39,6 +51,15 @@ public function handle(ConnectionVerifier $verifier): void
'error' => $e->getMessage(),
]);
} catch (TokenExpiredException $e) {
// Instagram and Threads extend their token in place and cannot
// renew it once expired, so a rejected extension means the
// connection is already doomed — say so while reconnecting still
// helps. Elsewhere a rejection often just means we lost a race.
if (! $this->account->platform->extendsAccessTokenOnRefresh()
&& $this->accessTokenStillWorks($verifier)) {
return;
}

$this->account->markAsTokenExpired($e->getMessage());
} catch (Throwable $e) {
Log::warning('Proactive token refresh failed', [
Expand All @@ -48,4 +69,48 @@ public function handle(ConnectionVerifier $verifier): void
]);
}
}

/**
* The only place this job reaches the (billed) verify endpoint, and only
* once a refresh has been rejected — which on X and Bluesky usually means
* a concurrent refresh consumed the single-use refresh_token first.
*/
private function accessTokenStillWorks(ConnectionVerifier $verifier): bool
{
try {
// The winner of that race persisted a new pair; ours is stale.
$this->account->refresh();

if (! $verifier->verifyAccessToken($this->account)) {
return false;
}

$this->recordVerification();

return true;
} catch (TokenExpiredException) {
return false;
} catch (PlatformUnavailableException|ConnectionException|ModelNotFoundException $e) {
// Only these three earn the benefit of the doubt. Reading any other
// failure as "healthy" leaves the account Connected forever while
// every publish hard-fails.
Log::warning('Access token fallback check failed after a rejected refresh', [
'account_id' => $this->account->id,
'platform' => $this->account->platform->value,
'error' => $e->getMessage(),
]);

return true;
}
}

/**
* Lets the daily sweep and the pre-publish check skip a verify of their
* own. Not done inside refreshToken(): refreshThenVerify() calls that too
* and can still fail on the verify that follows.
*/
private function recordVerification(): void
{
$this->account->update(['last_verified_at' => now()]);
}
}
33 changes: 28 additions & 5 deletions app/Jobs/VerifyWorkspaceConnections.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ class VerifyWorkspaceConnections implements ShouldQueue

public int $timeout = 120;

// A refresh stamps last_verified_at and replaces the access token, so
// there is nothing left for a billed read to confirm. On short-TTL
// platforms the stamp is never stale here and this sweep stops calling
// verify() entirely — intended, not an oversight.
private const VERIFIED_WITHIN_HOURS = 12;

public function __construct(public Workspace $workspace) {}

public function handle(ConnectionVerifier $verifier): void
Expand All @@ -42,12 +48,11 @@ public function handle(ConnectionVerifier $verifier): void
$disconnectedAccounts = collect();

foreach ($accounts as $account) {
if ($this->verifyAccount($verifier, $account)) {
// If was TokenExpired but now verified OK, mark as connected again
if ($account->status === Status::TokenExpired) {
$account->markAsConnected();
}
if ($this->recentlyProvenValid($account)) {
continue;
}

if ($this->verifyAccount($verifier, $account)) {
continue;
}

Expand All @@ -59,10 +64,28 @@ public function handle(ConnectionVerifier $verifier): void
}
}

/**
* Connected only: verifying a TokenExpired account is how it gets promoted
* back, so a stale stamp would strand one that has recovered.
*/
private function recentlyProvenValid(SocialAccount $account): bool
{
return $account->status === Status::Connected
&& $account->last_verified_at !== null
&& $account->last_verified_at->isAfter(now()->subHours(self::VERIFIED_WITHIN_HOURS));
}

private function verifyAccount(ConnectionVerifier $verifier, SocialAccount $account): bool
{
try {
$verifier->verify($account);
$account->update(['last_verified_at' => now()]);

// Here, not on this method's return value — that is also true
// for "could not check, don't disconnect".
if ($account->status === Status::TokenExpired) {
$account->markAsConnected();
}

return true;
} catch (PlatformUnavailableException $e) {
Expand Down
Loading