fix(social): distinguish platform-down from token-expired

When a provider's API was down (5xx, timeout, DNS), the hourly
RefreshSocialToken job and daily VerifyWorkspaceConnections job were
treating it as "token revoked" and emailing the user to reconnect.
Bluesky going offline triggered false-positive disconnect notifications
because Bluesky access tokens are short-lived (2h) so every hourly
refresh failed during the outage.

- New PlatformUnavailableException: API unreachable / 5xx, transient.
  TokenExpiredException stays for 4xx (token is provably bad).
- New TokenRefreshClient: normalizes failure semantics for OAuth
  refresh HTTP calls across all providers. Takes a Platform enum so
  typos fail at compile time and the user-facing label comes from
  one source.
- ConnectionVerifier: all 8 refresh*Token methods route through the
  new client. Hardcoded OAuth URLs (LinkedIn, YouTube) and Bluesky's
  default PDS host moved into config/trypost.php alongside the
  existing per-platform entries.
- RefreshSocialToken job: PlatformUnavailableException → log warning
  and stop. Do NOT markAsTokenExpired, do NOT notify the user. Next
  scheduled tick retries.
- VerifyWorkspaceConnections job: PlatformUnavailableException from
  the inner refresh propagates and is treated as a transient skip.
This commit is contained in:
Paulo Castellano 2026-05-19 08:16:43 -03:00
parent a0861a0b92
commit 6f96d67dbc
9 changed files with 309 additions and 108 deletions

View file

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Exceptions;
use Exception;
/**
* Raised when a social platform's API is unreachable or returning a server
* error during a token verify/refresh. Distinct from TokenExpiredException
* because the account's token is not provably invalid — the platform is
* just down. Callers should retry later instead of disconnecting the user.
*/
class PlatformUnavailableException extends Exception
{
public function __construct(
string $message = 'Platform API is unavailable',
public ?int $httpStatus = null,
) {
parent::__construct($message);
}
}

View file

@ -4,6 +4,7 @@
namespace App\Jobs;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\ConnectionVerifier;
@ -24,6 +25,16 @@ public function handle(ConnectionVerifier $verifier): void
{
try {
$verifier->refreshToken($this->account);
} catch (PlatformUnavailableException $e) {
// Platform is down (5xx / network). Leave the account alone —
// next scheduled run will try again. Critically, do NOT mark
// the account expired: that would trigger a false-positive
// "reconnect your account" notification.
Log::warning('Token refresh skipped: platform unavailable', [
'account_id' => $this->account->id,
'platform' => $this->account->platform->value,
'error' => $e->getMessage(),
]);
} catch (TokenExpiredException $e) {
// refresh_token rejected by the provider (revoked / rotated /
// expired beyond refresh). Mark the account so the user is

View file

@ -7,6 +7,7 @@
use App\Enums\Notification\Channel;
use App\Enums\Notification\Type;
use App\Enums\SocialAccount\Status;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Mail\WorkspaceConnectionsDisconnected;
use App\Models\SocialAccount;
@ -63,6 +64,16 @@ private function verifyAccount(ConnectionVerifier $verifier, SocialAccount $acco
try {
$verifier->verify($account);
return true;
} catch (PlatformUnavailableException $e) {
// Platform is down (5xx / network). The account's token is not
// provably bad — skip silently and try again next pass.
Log::warning('Social account verification skipped: platform unavailable', [
'account_id' => $account->id,
'platform' => $account->platform->value,
'error' => $e->getMessage(),
]);
return true;
} catch (TokenExpiredException $e) {
Log::warning('Social account connection is invalid', [

View file

@ -5,11 +5,11 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class ConnectionVerifier
{
@ -17,6 +17,7 @@ class ConnectionVerifier
* Verify that a social account connection is still valid.
*
* @throws TokenExpiredException if the connection is invalid
* @throws PlatformUnavailableException if the platform's API is down
*/
public function verify(SocialAccount $account): bool
{
@ -27,6 +28,9 @@ public function verify(SocialAccount $account): bool
// LinkedIn, etc.) invalidate the previous refresh_token on each
// refresh, so proactive refreshes during races cause false-positive
// disconnects even though the access_token still works fine.
//
// PlatformUnavailableException from refresh propagates naturally so
// the caller can distinguish "platform is down" from "token bad".
if ($account->is_token_expired) {
$this->refreshToken($account);
@ -113,17 +117,13 @@ private function refreshLinkedInToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for LinkedIn account');
}
$response = Http::asForm()->post('https://www.linkedin.com/oauth/v2/accessToken', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.linkedin.client_id'),
'client_secret' => config('services.linkedin.client_secret'),
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: LinkedIn token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh LinkedIn token');
}
$response = TokenRefreshClient::for(Platform::LinkedIn)->send(fn () => Http::asForm()
->post(config('trypost.platforms.linkedin.oauth_api').'/oauth/v2/accessToken', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.linkedin.client_id'),
'client_secret' => config('services.linkedin.client_secret'),
]));
$data = $response->json();
@ -145,17 +145,12 @@ private function refreshXToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for X account');
}
$response = Http::asForm()
$response = TokenRefreshClient::for(Platform::X)->send(fn () => Http::asForm()
->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret'))
->post(config('trypost.platforms.x.api').'/oauth2/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: X token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh X token');
}
]));
$data = $response->json();
@ -170,13 +165,16 @@ private function refreshXToken(SocialAccount $account): void
private function refreshBlueskyToken(SocialAccount $account): void
{
$service = $account->meta['service'] ?? 'https://bsky.social';
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
$client = TokenRefreshClient::for(Platform::Bluesky);
// Try refresh token first
$response = Http::withToken($account->refresh_token)
->post("{$service}/xrpc/com.atproto.server.refreshSession");
// Try refresh token first. Connection errors / 5xx surface as
// PlatformUnavailableException (Bluesky is down — don't touch the
// account's status). 4xx falls through to the re-auth fallback.
try {
$response = $client->send(fn () => Http::withToken($account->refresh_token)
->post("{$service}/xrpc/com.atproto.server.refreshSession"));
if ($response->successful()) {
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'accessJwt'),
@ -187,35 +185,29 @@ private function refreshBlueskyToken(SocialAccount $account): void
$account->refresh();
return;
} catch (TokenExpiredException) {
// refresh token was rejected (4xx) — fall back to re-auth below
}
// If refresh fails, re-authenticate with stored credentials
if (isset($account->meta['password'])) {
try {
$password = decrypt($account->meta['password']);
$identifier = $account->meta['identifier'];
$reauth = $client->send(fn () => Http::post("{$service}/xrpc/com.atproto.server.createSession", [
'identifier' => $account->meta['identifier'],
'password' => decrypt($account->meta['password']),
]));
$response = Http::post("{$service}/xrpc/com.atproto.server.createSession", [
'identifier' => $identifier,
'password' => $password,
$data = $reauth->json();
$account->update([
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
]);
if ($response->successful()) {
$data = $response->json();
$account->update([
'access_token' => data_get($data, 'accessJwt'),
'refresh_token' => data_get($data, 'refreshJwt'),
'token_expires_at' => now()->addHours(2),
]);
$account->refresh();
$account->refresh();
return;
}
} catch (\Exception $e) {
Log::error('ConnectionVerifier: Bluesky re-authentication failed', [
'error' => $e->getMessage(),
]);
return;
} catch (TokenExpiredException) {
// re-auth rejected with stored credentials — fall through
}
}
@ -228,17 +220,13 @@ private function refreshYouTubeToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for YouTube account');
}
$response = Http::asForm()->post('https://oauth2.googleapis.com/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.google.client_id'),
'client_secret' => config('services.google.client_secret'),
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: YouTube token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh YouTube token');
}
$response = TokenRefreshClient::for(Platform::YouTube)->send(fn () => Http::asForm()
->post(config('trypost.platforms.youtube.oauth_api').'/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_id' => config('services.google.client_id'),
'client_secret' => config('services.google.client_secret'),
]));
$data = $response->json();
@ -256,17 +244,13 @@ private function refreshTikTokToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for TikTok account');
}
$response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: TikTok token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh TikTok token');
}
$response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm()
->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
]));
$data = $response->json();
@ -287,18 +271,13 @@ private function refreshPinterestToken(SocialAccount $account): void
$credentials = base64_encode(config('services.pinterest.client_id').':'.config('services.pinterest.client_secret'));
$response = Http::withHeaders([
$response = TokenRefreshClient::for(Platform::Pinterest)->send(fn () => Http::withHeaders([
'Authorization' => "Basic {$credentials}",
'Content-Type' => 'application/x-www-form-urlencoded',
])->asForm()->post(config('trypost.platforms.pinterest.api').'/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: Pinterest token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh Pinterest token');
}
]));
$data = $response->json();
@ -314,15 +293,10 @@ private function refreshPinterestToken(SocialAccount $account): void
private function refreshThreadsToken(SocialAccount $account): void
{
// Threads uses long-lived tokens that can be refreshed
$response = Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
$response = TokenRefreshClient::for(Platform::Threads)->send(fn () => Http::get(config('trypost.platforms.threads.auth_api').'/refresh_access_token', [
'grant_type' => 'th_refresh_token',
'access_token' => $account->access_token,
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: Threads token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh Threads token');
}
]));
$data = $response->json();
$newToken = data_get($data, 'access_token');
@ -338,15 +312,10 @@ private function refreshThreadsToken(SocialAccount $account): void
private function refreshInstagramToken(SocialAccount $account): void
{
$response = Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
$response = TokenRefreshClient::for(Platform::Instagram)->send(fn () => Http::get(config('trypost.platforms.instagram.auth_api').'/refresh_access_token', [
'grant_type' => 'ig_refresh_token',
'access_token' => $account->access_token,
]);
if ($response->failed()) {
Log::error('ConnectionVerifier: Instagram token refresh failed', ['body' => $this->redactBody($response->body())]);
throw new TokenExpiredException('Failed to refresh Instagram token');
}
]));
$data = $response->json();
$newToken = data_get($data, 'access_token');
@ -524,7 +493,7 @@ private function verifyPinterest(SocialAccount $account): bool
private function verifyBluesky(SocialAccount $account): bool
{
$service = $account->meta['service'] ?? 'https://bsky.social';
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
$response = Http::withToken($account->access_token)
->get("{$service}/xrpc/app.bsky.actor.getProfile", [
@ -554,23 +523,4 @@ private function verifyMastodon(SocialAccount $account): bool
return $response->successful();
}
private function redactBody(string $body): string
{
return preg_replace(
[
'/access_token=([^&"\s]+)/',
'/"access_token"\s*:\s*"([^"]+)"/',
'/Bearer\s+\S+/',
'/"token"\s*:\s*"([^"]+)"/',
],
[
'access_token=[REDACTED]',
'"access_token":"[REDACTED]"',
'Bearer [REDACTED]',
'"token":"[REDACTED]"',
],
$body
);
}
}

View file

@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use Closure;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Log;
/**
* Normalizes the failure modes of an OAuth token-refresh request:
*
* - ConnectionException (timeout / DNS / refused) → PlatformUnavailableException
* - HTTP 5xx → PlatformUnavailableException
* - HTTP 4xx → TokenExpiredException
*
* Callers configure the actual HTTP call through the closure passed to
* `send()`, so platform-specific quirks (form vs JSON body, auth headers,
* basic auth, etc.) stay where they belong — in the per-platform refresh
* method — while the failure semantics are uniform across providers.
*/
class TokenRefreshClient
{
public function __construct(public readonly Platform $platform) {}
public static function for(Platform $platform): self
{
return new self($platform);
}
/**
* @param Closure():Response $request
*
* @throws PlatformUnavailableException
* @throws TokenExpiredException
*/
public function send(Closure $request): Response
{
$name = $this->platform->label();
try {
$response = $request();
} catch (ConnectionException $e) {
throw new PlatformUnavailableException("{$name} API unreachable: {$e->getMessage()}");
}
if ($response->serverError()) {
throw new PlatformUnavailableException(
"{$name} API returned {$response->status()} during token refresh",
$response->status(),
);
}
if ($response->failed()) {
Log::error("TokenRefreshClient: {$name} token refresh failed", [
'body' => $this->redactBody($response->body()),
]);
throw new TokenExpiredException("Failed to refresh {$name} token");
}
return $response;
}
private function redactBody(string $body): string
{
return preg_replace(
[
'/access_token=([^&"\s]+)/',
'/"access_token"\s*:\s*"([^"]+)"/',
'/Bearer\s+\S+/',
'/"token"\s*:\s*"([^"]+)"/',
],
[
'access_token=[REDACTED]',
'"access_token":"[REDACTED]"',
'Bearer [REDACTED]',
'"token":"[REDACTED]"',
],
$body
);
}
}

View file

@ -63,6 +63,8 @@
'linkedin' => [
'enabled' => env('LINKEDIN_ENABLED', true),
'api' => env('LINKEDIN_API', 'https://api.linkedin.com'),
// OAuth host is different from the data API (api.linkedin.com).
'oauth_api' => env('LINKEDIN_OAUTH_API', 'https://www.linkedin.com'),
],
'linkedin-page' => [
'enabled' => env('LINKEDIN_PAGE_ENABLED', true),
@ -80,6 +82,7 @@
'enabled' => env('YOUTUBE_ENABLED', true),
'data_api' => env('YOUTUBE_DATA_API', 'https://www.googleapis.com/youtube/v3'),
'analytics_api' => env('YOUTUBE_ANALYTICS_API', 'https://youtubeanalytics.googleapis.com/v2'),
'oauth_api' => env('YOUTUBE_OAUTH_API', 'https://oauth2.googleapis.com'),
],
'facebook' => [
'enabled' => env('FACEBOOK_ENABLED', true),
@ -108,6 +111,8 @@
'bluesky' => [
'enabled' => env('BLUESKY_ENABLED', true),
'public_appview' => env('BLUESKY_PUBLIC_APPVIEW', 'https://public.api.bsky.app'),
// Default PDS used when the account has no `meta.service` override.
'default_service' => env('BLUESKY_DEFAULT_SERVICE', 'https://bsky.social'),
],
'mastodon' => [
'enabled' => env('MASTODON_ENABLED', true),

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Enums\SocialAccount\Status;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Jobs\RefreshSocialToken;
use App\Jobs\SendNotification;
@ -67,3 +68,25 @@
expect($this->account->fresh()->status)->toBe(Status::Connected);
});
test('refresh job does NOT mark account expired when platform is unavailable', function () {
Queue::fake();
Log::shouldReceive('warning')->once()->withArgs(function ($message, $context) {
return $message === 'Token refresh skipped: platform unavailable'
&& $context['account_id'] === $this->account->id
&& str_contains($context['error'], '503');
});
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('refreshToken')->once()->andThrow(
new PlatformUnavailableException('X API returned 503 during token refresh', 503)
);
app()->instance(ConnectionVerifier::class, $verifier);
(new RefreshSocialToken($this->account))->handle($verifier);
// Critically: account status stays Connected, no notification dispatched.
expect($this->account->fresh()->status)->toBe(Status::Connected);
Queue::assertNotPushed(SendNotification::class);
});

View file

@ -2,9 +2,11 @@
declare(strict_types=1);
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\ConnectionVerifier;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
test('verifies account without refresh when token is not expired', function () {
@ -360,3 +362,71 @@
expect(fn () => $verifier->verify($account))->toThrow(TokenExpiredException::class);
});
test('5xx during refresh raises PlatformUnavailableException, not TokenExpiredException', function () {
Http::fake([
'bsky.social/xrpc/com.atproto.server.refreshSession' => Http::response('upstream timeout', 503),
]);
$account = SocialAccount::factory()->bluesky()->create([
'token_expires_at' => now()->subMinutes(5),
'refresh_token' => 'old_refresh_token',
'meta' => ['service' => 'https://bsky.social'],
]);
$verifier = new ConnectionVerifier;
expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class);
});
test('connection failure during refresh raises PlatformUnavailableException', function () {
Http::fake([
'oauth2.googleapis.com/token' => fn () => throw new ConnectionException('cURL error 7: connection refused'),
]);
$account = SocialAccount::factory()->youtube()->create([
'token_expires_at' => now()->subMinutes(5),
'refresh_token' => 'old_refresh_token',
]);
$verifier = new ConnectionVerifier;
expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class);
});
test('4xx during refresh keeps raising TokenExpiredException', function () {
Http::fake([
'api.x.com/2/oauth2/token' => Http::response(['error' => 'invalid_grant'], 400),
]);
$account = SocialAccount::factory()->x()->create([
'token_expires_at' => now()->subMinutes(5),
'refresh_token' => 'old_refresh_token',
]);
$verifier = new ConnectionVerifier;
expect(fn () => $verifier->refreshToken($account))->toThrow(TokenExpiredException::class);
});
test('bluesky 5xx during refresh raises PlatformUnavailable even when password fallback is stored', function () {
Http::fake([
// Both endpoints return 5xx — the platform is genuinely down.
'bsky.social/xrpc/com.atproto.server.refreshSession' => Http::response('upstream timeout', 503),
'bsky.social/xrpc/com.atproto.server.createSession' => Http::response('upstream timeout', 503),
]);
$account = SocialAccount::factory()->bluesky()->create([
'token_expires_at' => now()->subMinutes(5),
'refresh_token' => 'old_refresh_token',
'meta' => [
'service' => 'https://bsky.social',
'identifier' => 'user.bsky.social',
'password' => encrypt('app-password'),
],
]);
$verifier = new ConnectionVerifier;
expect(fn () => $verifier->refreshToken($account))->toThrow(PlatformUnavailableException::class);
});

View file

@ -3,6 +3,7 @@
declare(strict_types=1);
use App\Enums\SocialAccount\Status;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Jobs\VerifyWorkspaceConnections;
use App\Mail\WorkspaceConnectionsDisconnected;
@ -121,6 +122,26 @@
});
});
test('job does NOT disconnect or email when platform is unavailable', function () {
Mail::fake();
$workspace = Workspace::factory()->create();
$account = SocialAccount::factory()->bluesky()->create(['workspace_id' => $workspace->id]);
$verifier = mock(ConnectionVerifier::class);
$verifier->shouldReceive('verify')->andThrow(
new PlatformUnavailableException('Bluesky API returned 503 during token refresh', 503)
);
app()->instance(ConnectionVerifier::class, $verifier);
VerifyWorkspaceConnections::dispatch($workspace);
// Status untouched, no notification email.
expect($account->fresh()->status)->toBe(Status::Connected);
Mail::assertNothingQueued();
});
test('job skips already disconnected accounts', function () {
Mail::fake();