feat(social): reschedule publish on PlatformUnavailable instead of failing

Before: a scheduled post hitting a platform outage was marked Failed —
user had to manually retry. Now the job reschedules itself for 10
minutes later and the PostPlatform shows status "Retrying". Loops
indefinitely until the platform accepts the post.

- Adds PostPlatformStatus::Retrying (existing string column, no migration)
- PublishToSocialPlatform: PlatformUnavailable catch now calls
  rescheduleForRetry() which (a) updates the row to Retrying with
  retry_count + next_attempt_at in error_context, and (b) dispatches
  itself with a 10-minute delay. updatePostStatus() naturally leaves
  the parent Post in Publishing because Retrying is neither Published
  nor Failed.
- Same treatment for the retry-refresh edge case (publisher throws
  TokenExpired, refresh subsequently fails with PlatformUnavailable).
- i18n + frontend status config updated (en, pt-BR, es) for both
  posts.status.retrying and posts.edit.status.retrying.
- Tests: 3 new tests covering the dispatched job, the edge case path,
  and retry_count increment across attempts.
This commit is contained in:
Paulo Castellano 2026-05-19 10:03:30 -03:00
parent 6c6078fa6a
commit d336f79059
10 changed files with 87 additions and 39 deletions

View file

@ -8,6 +8,7 @@ enum Status: string
{
case Pending = 'pending';
case Publishing = 'publishing';
case Retrying = 'retrying';
case Published = 'published';
case Failed = 'failed';
}

View file

@ -112,17 +112,7 @@ public function handle(): void
$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(),
]);
$this->rescheduleForRetry($e);
break;
} catch (TokenExpiredException $e) {
if ($attempt < $maxAttempts) {
@ -131,17 +121,7 @@ public function handle(): void
continue;
} catch (PlatformUnavailableException $refreshError) {
Log::warning('Publish skipped: platform unavailable during retry refresh', [
'post_platform_id' => $this->postPlatform->id,
'platform' => $this->postPlatform->platform->value,
'error' => $refreshError->getMessage(),
]);
$this->postPlatform->markAsFailed($refreshError->getMessage(), [
'category' => 'platform_unavailable',
'http_status' => $refreshError->httpStatus,
'failed_at' => now()->toIso8601String(),
]);
$this->rescheduleForRetry($refreshError);
break;
} catch (\Throwable $refreshError) {
Log::error('Token refresh failed during publish retry', [
@ -208,6 +188,34 @@ private function refreshAccountToken(): void
app(ConnectionVerifier::class)->verify($account);
}
private function rescheduleForRetry(PlatformUnavailableException $e): void
{
$retryCount = (int) ($this->postPlatform->error_context['retry_count'] ?? 0) + 1;
$nextAttemptAt = now()->addMinutes(10);
Log::warning('Publish rescheduled: platform unavailable', [
'post_platform_id' => $this->postPlatform->id,
'platform' => $this->postPlatform->platform->value,
'retry_count' => $retryCount,
'next_attempt_at' => $nextAttemptAt->toIso8601String(),
'error' => $e->getMessage(),
]);
$this->postPlatform->update([
'status' => PostPlatformStatus::Retrying,
'error_message' => $e->getMessage(),
'error_context' => [
'category' => 'platform_unavailable',
'http_status' => $e->httpStatus,
'retry_count' => $retryCount,
'last_attempt_at' => now()->toIso8601String(),
'next_attempt_at' => $nextAttemptAt->toIso8601String(),
],
]);
self::dispatch($this->postPlatform)->delay($nextAttemptAt);
}
private function broadcastStatus(): void
{
PostPlatformStatusUpdated::dispatch($this->postPlatform->fresh());

View file

@ -175,6 +175,7 @@
'draft' => 'Draft',
'scheduled' => 'Scheduled',
'publishing' => 'Publishing',
'retrying' => 'Retrying',
'published' => 'Published',
'partially_published' => 'Partially Published',
'failed' => 'Failed',
@ -323,6 +324,7 @@
'scheduled' => 'Scheduled',
'published' => 'Published',
'publishing' => 'Publishing...',
'retrying' => 'Retrying...',
'failed' => 'Failed',
],

View file

@ -175,6 +175,7 @@
'draft' => 'Borrador',
'scheduled' => 'Programado',
'publishing' => 'Publicando',
'retrying' => 'Reintentando',
'published' => 'Publicado',
'partially_published' => 'Parcialmente publicado',
'failed' => 'Fallido',
@ -323,6 +324,7 @@
'scheduled' => 'Programado',
'published' => 'Publicado',
'publishing' => 'Publicando...',
'retrying' => 'Reintentando...',
'failed' => 'Fallido',
],

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -175,6 +175,7 @@
'draft' => 'Rascunho',
'scheduled' => 'Agendado',
'publishing' => 'Publicando',
'retrying' => 'Tentando novamente',
'published' => 'Publicado',
'partially_published' => 'Parcialmente Publicado',
'failed' => 'Falhou',
@ -323,6 +324,7 @@
'scheduled' => 'Agendado',
'published' => 'Publicado',
'publishing' => 'Publicando...',
'retrying' => 'Tentando novamente...',
'failed' => 'Falhou',
],

View file

@ -19,6 +19,7 @@ const CONFIGS: Record<string, Pick<StatusConfig, 'variant' | 'icon'>> = {
draft: { variant: 'outline', icon: IconFileText },
scheduled: { variant: 'default', icon: IconClock },
publishing: { variant: 'warning', icon: IconLoader2 },
retrying: { variant: 'warning', icon: IconLoader2 },
published: { variant: 'success', icon: IconCircleCheck },
partially_published: { variant: 'warning', icon: IconAlertCircle },
failed: { variant: 'destructive', icon: IconAlertCircle },
@ -33,6 +34,7 @@ export const getPlatformStatusConfig = (status: string): StatusConfig => {
const map: Record<string, string> = {
pending: 'draft',
publishing: 'publishing',
retrying: 'retrying',
published: 'published',
failed: 'failed',
};

View file

@ -7,9 +7,9 @@
use App\Enums\SocialAccount\Status as AccountStatus;
use App\Enums\UserWorkspace\Role;
use App\Events\PostPlatformStatusUpdated;
use App\Exceptions\PlatformUnavailableException;
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;
@ -20,6 +20,7 @@
use App\Models\Workspace;
use App\Services\Social\ConnectionVerifier;
use App\Services\Social\LinkedInPublisher;
use Illuminate\Support\Facades\Bus;
use Illuminate\Support\Facades\Event;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\Queue;
@ -110,14 +111,42 @@
expect($this->socialAccount->status)->toBe(AccountStatus::TokenExpired);
});
test('publish does NOT mark account expired when retry refresh hits platform unavailable', function () {
test('publish reschedules platform unavailable retry via Bus dispatch (not marked Failed, not expired)', function () {
Bus::fake([PublishToSocialPlatform::class]);
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::Retrying);
expect($this->postPlatform->error_context['category'] ?? null)->toBe('platform_unavailable');
expect($this->postPlatform->error_context['http_status'] ?? null)->toBe(503);
expect($this->postPlatform->error_context['retry_count'] ?? null)->toBe(1);
expect($this->socialAccount->status)->toBe(AccountStatus::Connected);
Bus::assertDispatched(PublishToSocialPlatform::class, function ($job) {
return $job->postPlatform->id === $this->postPlatform->id;
});
});
test('publish reschedules retry when retry-refresh path hits platform unavailable', function () {
Bus::fake([PublishToSocialPlatform::class]);
Event::fake();
Mail::fake();
// Publisher first throws TokenExpired (401-style), the retry-refresh
// path goes through ConnectionVerifier::verify which can in turn raise
// PlatformUnavailable if the platform is down. The account must stay
// Connected — it was the platform that failed, not the token.
// PlatformUnavailable if the platform is down.
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(new TokenExpiredException('Token expired', '401'));
@ -134,32 +163,34 @@
$this->postPlatform->refresh();
$this->socialAccount->refresh();
expect($this->postPlatform->status)->toBe(PlatformStatus::Failed);
expect($this->postPlatform->status)->toBe(PlatformStatus::Retrying);
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);
Bus::assertDispatched(PublishToSocialPlatform::class);
});
test('publish to social platform does NOT mark account expired when platform is unavailable', function () {
test('publish retry count increments across successive platform_unavailable attempts', function () {
Bus::fake([PublishToSocialPlatform::class]);
Event::fake();
Mail::fake();
$publisher = Mockery::mock(LinkedInPublisher::class);
$publisher->shouldReceive('publish')->andThrow(
new PlatformUnavailableException('LinkedIn API returned 503 during token refresh', 503)
new PlatformUnavailableException('LinkedIn 503', 503)
);
$this->app->instance(LinkedInPublisher::class, $publisher);
// Simulate prior attempts
$this->postPlatform->update([
'error_context' => ['retry_count' => 5],
]);
(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);
expect($this->postPlatform->error_context['retry_count'] ?? null)->toBe(6);
});
test('publish to social platform updates post status when all platforms finished', function () {