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:
parent
6c6078fa6a
commit
d336f79059
10 changed files with 87 additions and 39 deletions
|
|
@ -8,6 +8,7 @@ enum Status: string
|
|||
{
|
||||
case Pending = 'pending';
|
||||
case Publishing = 'publishing';
|
||||
case Retrying = 'retrying';
|
||||
case Published = 'published';
|
||||
case Failed = 'failed';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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',
|
||||
],
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 () {
|
||||
|
|
|
|||
Loading…
Reference in a new issue