fix(social): publish flow honors PlatformUnavailable too

The previous commits in this PR closed the loophole on the hourly /
daily token-refresh jobs. The same loophole remained on the publish
path: every per-platform publisher (LinkedIn, X, YouTube, TikTok,
Threads, Instagram, Pinterest, Bluesky and their Analytics siblings)
has its own refreshToken() called before publishing a scheduled post,
and all of those treated any non-2xx as TokenExpired — including 5xx.

Result before this commit: a Bluesky outage that coincided with a
scheduled publish would mark the account as expired and fail the post.

Changes:
- Route every refreshToken() in the 16 publisher / analytics classes
  through TokenRefreshClient::for(Platform::X)->send(...).
- TokenRefreshClient now also fills platformErrorCode from the HTTP
  status and pulls error_description / error.message from the JSON
  body, preserving the richer info LinkedIn / X / TikTok / Pinterest /
  Threads used to put on their TokenExpiredException.
- PublishToSocialPlatform catches PlatformUnavailableException
  explicitly: the post is marked failed (category: platform_unavailable,
  with http_status in error_context) but the account stays Connected.
  No retry inside this job — the scheduler reattempts the next run.

Test added: publish flow does NOT mark account expired when the
publisher throws PlatformUnavailable. Full suite: 1569 passing.
This commit is contained in:
Paulo Castellano 2026-05-19 09:01:02 -03:00
parent 090cc761dd
commit 32e1f89adb
20 changed files with 144 additions and 192 deletions

View file

@ -10,6 +10,7 @@
use App\Enums\SocialAccount\Platform as SocialPlatform;
use App\Enums\SocialAccount\Status;
use App\Events\PostPlatformStatusUpdated;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\Social\SocialPublishException;
use App\Exceptions\TokenExpiredException;
use App\Mail\PostPublished;
@ -110,6 +111,19 @@ public function handle(): void
$result = $publisher->publish($this->postPlatform);
$this->postPlatform->markAsPublished(data_get($result, 'id'), data_get($result, 'url'));
break;
} catch (PlatformUnavailableException $e) {
Log::warning('Publish skipped: platform unavailable', [
'post_platform_id' => $this->postPlatform->id,
'platform' => $this->postPlatform->platform->value,
'error' => $e->getMessage(),
]);
$this->postPlatform->markAsFailed($e->getMessage(), [
'category' => 'platform_unavailable',
'http_status' => $e->httpStatus,
'failed_at' => now()->toIso8601String(),
]);
break;
} catch (TokenExpiredException $e) {
if ($attempt < $maxAttempts) {
try {

View file

@ -271,12 +271,12 @@ private function buildPostUrl(string $handle, string $postId): string
public function refreshToken(SocialAccount $account): void
{
$service = $account->meta['service'] ?? config('trypost.platforms.bluesky.default_service');
$client = TokenRefreshClient::for(Platform::Bluesky);
// Try refresh first
$response = $this->socialHttp()->withToken($account->refresh_token)
->post("{$service}/xrpc/com.atproto.server.refreshSession");
try {
$response = $client->send(fn () => $this->socialHttp()->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'),
@ -285,37 +285,27 @@ public function refreshToken(SocialAccount $account): void
]);
return;
} catch (TokenExpiredException) {
// refresh token rejected (4xx) — fall back to re-auth below
}
Log::warning('Bluesky refresh token failed, trying re-authentication', [
'status' => $response->status(),
]);
// 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),
]);
return;
}
} catch (\Exception $e) {
Log::error('Bluesky re-authentication failed', [
'error' => $e->getMessage(),
]);
return;
} catch (TokenExpiredException) {
// re-auth rejected with stored credentials — fall through
}
}

View file

@ -211,15 +211,10 @@ private function refreshToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for Instagram account');
}
$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('Instagram token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('Instagram token refresh failed');
}
]));
$data = $response->json();

View file

@ -8,7 +8,6 @@
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\InstagramPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
@ -403,16 +402,10 @@ private function refreshToken(SocialAccount $account): void
return;
}
$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('Instagram token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('Failed to refresh Instagram token');
}
]));
$data = $response->json();
$newToken = data_get($data, 'access_token');

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
@ -240,17 +241,13 @@ private function refreshToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for LinkedIn Page account');
}
$response = 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-openid.client_id'),
'client_secret' => config('services.linkedin-openid.client_secret'),
]);
if ($response->failed()) {
Log::error('LinkedIn token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('LinkedIn token refresh failed');
}
$response = TokenRefreshClient::for(Platform::LinkedInPage)->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-openid.client_id'),
'client_secret' => config('services.linkedin-openid.client_secret'),
]));
$data = $response->json();

View file

@ -458,19 +458,13 @@ private function refreshToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for LinkedIn Page account');
}
$response = 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-openid.client_id'),
'client_secret' => config('services.linkedin-openid.client_secret'),
]);
if ($response->failed()) {
throw new TokenExpiredException(
message: data_get($response->json(), 'error_description', 'Failed to refresh LinkedIn Page token'),
platformErrorCode: (string) $response->status(),
);
}
$response = TokenRefreshClient::for(Platform::LinkedInPage)->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-openid.client_id'),
'client_secret' => config('services.linkedin-openid.client_secret'),
]));
$data = $response->json();

View file

@ -439,19 +439,13 @@ private function refreshToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for LinkedIn account');
}
$response = 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'),
]);
if ($response->failed()) {
throw new TokenExpiredException(
message: data_get($response->json(), 'error_description', 'Failed to refresh LinkedIn token'),
platformErrorCode: (string) $response->status(),
);
}
$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();

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
@ -167,18 +168,13 @@ private function refreshToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for Pinterest account');
}
$response = Http::withBasicAuth(
$response = TokenRefreshClient::for(Platform::Pinterest)->send(fn () => Http::withBasicAuth(
config('services.pinterest.client_id'),
config('services.pinterest.client_secret'),
)->asForm()->post(config('trypost.platforms.pinterest.api').'/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('Pinterest token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('Pinterest token refresh failed');
}
]));
$data = $response->json();

View file

@ -8,7 +8,6 @@
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\PinterestPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Media\MediaOptimizer;
@ -403,7 +402,7 @@ private function waitForMediaProcessing(SocialAccount $account, string $mediaId,
private function refreshToken(SocialAccount $account): void
{
$response = Http::asForm()
$response = TokenRefreshClient::for(Platform::Pinterest)->send(fn () => Http::asForm()
->withBasicAuth(
config('services.pinterest.client_id'),
config('services.pinterest.client_secret')
@ -411,14 +410,7 @@ private function refreshToken(SocialAccount $account): void
->post($this->baseUrl.'/oauth/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
throw new TokenExpiredException(
message: data_get($response->json(), 'error_description', 'Failed to refresh Pinterest token'),
platformErrorCode: (string) $response->status(),
);
}
]));
$data = $response->json();

View file

@ -4,7 +4,7 @@
namespace App\Services\Social;
use App\Exceptions\TokenExpiredException;
use App\Enums\SocialAccount\Platform;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
@ -141,15 +141,10 @@ private function getHttpClient(): PendingRequest
private function refreshToken(SocialAccount $account): void
{
$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('Threads token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('Threads token refresh failed');
}
]));
$data = $response->json();

View file

@ -4,8 +4,8 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ThreadsPublishException;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
@ -306,17 +306,10 @@ private function waitForMediaProcessing(string $containerId, string $accessToken
private function refreshToken(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()) {
throw new TokenExpiredException(
message: data_get($response->json(), 'error.message', 'Failed to refresh Threads token'),
platformErrorCode: (string) $response->status(),
);
}
]));
$data = $response->json();

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
@ -166,17 +167,13 @@ private function refreshToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for TikTok account');
}
$response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('TikTok token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('TikTok token refresh failed');
}
$response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm()
->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]));
$data = $response->json();

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
use App\Models\SocialAccount;
use App\Services\Social\Concerns\HasSocialHttpClient;
@ -130,17 +131,13 @@ private function refreshToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for TikTok account');
}
$response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('TikTok token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('TikTok token refresh failed');
}
$response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm()
->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]));
$data = $response->json();

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\TikTokPublishException;
use App\Exceptions\TokenExpiredException;
@ -322,19 +323,13 @@ private function refreshToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for TikTok account');
}
$response = Http::asForm()->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
throw new TokenExpiredException(
message: data_get($response->json(), 'error.message', 'Failed to refresh TikTok token'),
platformErrorCode: (string) $response->status(),
);
}
$response = TokenRefreshClient::for(Platform::TikTok)->send(fn () => Http::asForm()
->post(config('trypost.platforms.tiktok.api').'/oauth/token/', [
'client_key' => config('services.tiktok.client_id'),
'client_secret' => config('services.tiktok.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]));
$data = $response->json();

View file

@ -60,7 +60,13 @@ public function send(Closure $request): Response
Log::error("TokenRefreshClient: {$name} token refresh failed", [
'body' => $this->redactBody($response->body()),
]);
throw new TokenExpiredException("Failed to refresh {$name} token");
$body = $response->json();
$message = data_get($body, 'error_description')
?? data_get($body, 'error.message')
?? "Failed to refresh {$name} token";
throw new TokenExpiredException($message, platformErrorCode: (string) $response->status());
}
return $response;

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
@ -206,18 +207,13 @@ private function refreshToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for X account');
}
$response = $this->socialHttp()
$response = TokenRefreshClient::for(Platform::X)->send(fn () => $this->socialHttp()
->withBasicAuth(config('services.x.client_id'), config('services.x.client_secret'))
->asForm()
->post(config('trypost.platforms.x.api').'/oauth2/token', [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('X token refresh failed', ['body' => $this->redactResponseBody($response->body())]);
throw new TokenExpiredException('X token refresh failed');
}
]));
$data = $response->json();

View file

@ -335,19 +335,12 @@ private function refreshToken(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("{$this->baseUrl}/oauth2/token", [
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
throw new TokenExpiredException(
message: data_get($response->json(), 'error_description', 'Failed to refresh X token'),
platformErrorCode: (string) $response->status(),
);
}
]));
$data = $response->json();

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\TokenExpiredException;
use App\Models\PostPlatform;
use App\Models\SocialAccount;
@ -168,18 +169,13 @@ private function refreshToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for YouTube account');
}
$response = Http::asForm()->post(config('trypost.platforms.youtube.oauth_api').'/token', [
'client_id' => config('services.google.client_id'),
'client_secret' => config('services.google.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('YouTube token refresh failed', ['body' => $this->redactResponseBody($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', [
'client_id' => config('services.google.client_id'),
'client_secret' => config('services.google.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]));
$data = $response->json();

View file

@ -4,6 +4,7 @@
namespace App\Services\Social;
use App\Enums\SocialAccount\Platform;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\YouTubePublishException;
use App\Exceptions\TokenExpiredException;
@ -229,18 +230,13 @@ private function refreshToken(SocialAccount $account): void
throw new TokenExpiredException('No refresh token available for YouTube account');
}
$response = Http::asForm()->post(config('trypost.platforms.youtube.oauth_api').'/token', [
'client_id' => config('services.google.client_id'),
'client_secret' => config('services.google.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]);
if ($response->failed()) {
Log::error('YouTube token refresh failed', ['body' => $this->redactResponseBody($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', [
'client_id' => config('services.google.client_id'),
'client_secret' => config('services.google.client_secret'),
'grant_type' => 'refresh_token',
'refresh_token' => $account->refresh_token,
]));
$data = $response->json();

View file

@ -9,6 +9,7 @@
use App\Events\PostPlatformStatusUpdated;
use App\Exceptions\Social\ErrorCategory;
use App\Exceptions\Social\LinkedInPublishException;
use App\Exceptions\PlatformUnavailableException;
use App\Exceptions\TokenExpiredException;
use App\Jobs\PublishToSocialPlatform;
use App\Jobs\SendNotification;
@ -109,6 +110,28 @@
expect($this->socialAccount->status)->toBe(AccountStatus::TokenExpired);
});
test('publish to social platform does NOT mark account expired when platform is unavailable', function () {
Event::fake();
Mail::fake();
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(
new PlatformUnavailableException('LinkedIn API returned 503 during token refresh', 503)
);
$this->app->instance(LinkedInPublisher::class, $publisher);
(new PublishToSocialPlatform($this->postPlatform))->handle();
$this->postPlatform->refresh();
$this->socialAccount->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
expect($this->postPlatform->error_context['category'] ?? null)->toBe('platform_unavailable');
expect($this->postPlatform->error_context['http_status'] ?? null)->toBe(503);
expect($this->socialAccount->status)->toBe(AccountStatus::Connected);
});
test('publish to social platform updates post status when all platforms finished', function () {
Event::fake();