* Improve asynchronous social publishing reliability
* fix: resume asynchronous social publishes
* fix: preserve publish checkpoints across retries
* fix: harden resumable publish lifecycle
* fix: clean retry resources on terminal failures
* test: cover resumable social publishing edge cases
* feat: add failed post retry command
* chore: remove retry command ai rule
* fix: require confirmation for post retries
* chore: remove ai rules index
* chore: remove ai social rule
* refactor: clarify TikTok derivative path validation
* refactor: simplify social publishing retries
* refactor: further simplify social publishing retries
* refactor: retry all failed post platforms
* style: import throwable in social retries
* refactor: decouple TikTok cleanup from image format
* refactor: extract missing publish scopes
* refactor: encapsulate missing scope failure
* fix: resume failed publishes and treat Instagram rate limits as transient
Keep TikTok/Instagram checkpoints on posts:retry so a manual retry does not
start a duplicate remote post. Classify Meta BUC 400s on Instagram status
polls as retryable via GraphError.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: cover resume paths and transient Instagram rate limits
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: resume posts:retry only for in-flight publish failures
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: resume posts:retry via ErrorCategory instead of string lists
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: handle Instagram PUBLISHED and EXPIRED container statuses
Treat EXPIRED as a terminal server error so posts:retry starts over, and complete already-published containers without a second media_publish.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: recover published Instagram stories from /stories
Stories are not on GET /{ig-user-id}/media. Resume a PUBLISHED story container from the stories edge so we do not bind a feed post id.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: cover Instagram EXPIRED retry and published recovery paths
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: stop guessing Instagram media ids from recent /media
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: checkpoint TikTok publish_id and keep in-flight photo derivatives
Persist publish_id right after /init/ so a crash can resume without a second publish. Keep hosted photos while that id is resumable, including token expiry on status fetch; prune only after success or a confirmed remote failure.
Co-authored-by: Cursor <cursoragent@cursor.com>
* test: cover remaining TikTok in-flight derivative edge cases
Guard the empty publish_id prune path, account guards without a checkpoint, and video status 401 after /init/.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: map TikTok publish statuses with an official enum
Use PublishStatus for status/fetch values from the Content Posting API. Keep only the documented cases, including FAILED as the terminal failure.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor: share in-flight publish checkpoint keys
Read TikTok and Instagram resume state through one helper so publishers, posts:retry, and derivative cleanup agree on the same keys.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: retry Instagram media_publish after transient Graph failures
A 500/code 2 after Meta already published left the job Failed as unknown.
Treat that as still-processing so resume can confirm PUBLISHED instead of posting again.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix: resume Instagram publish after dropped Graph connections
A timeout or connection reset after Meta already published was marked unknown.
Treat it as still-processing so resume can confirm PUBLISHED instead of posting again.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
202 lines
6.5 KiB
PHP
202 lines
6.5 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Console\Commands;
|
|
|
|
use App\Enums\Post\Status as PostStatus;
|
|
use App\Enums\PostPlatform\Status as PlatformStatus;
|
|
use App\Enums\SocialAccount\Platform as SocialPlatform;
|
|
use App\Exceptions\Social\ErrorCategory;
|
|
use App\Jobs\PublishToSocialPlatform;
|
|
use App\Models\Post;
|
|
use App\Models\PostPlatform;
|
|
use App\Support\Social\PublishCheckpoint;
|
|
use App\Support\Social\TikTokPhotoDerivativeCleaner;
|
|
use Illuminate\Console\Command;
|
|
use Illuminate\Database\Eloquent\Builder;
|
|
use Illuminate\Database\Eloquent\Collection;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Log;
|
|
|
|
class RetryFailedPost extends Command
|
|
{
|
|
protected $signature = 'posts:retry
|
|
{post : ID of the post whose failed platforms should be retried}';
|
|
|
|
protected $description = 'Retry failed platforms, resuming in-flight remote publishes when a checkpoint exists';
|
|
|
|
public function __construct(
|
|
private readonly TikTokPhotoDerivativeCleaner $tiktokPhotoDerivativeCleaner,
|
|
) {
|
|
parent::__construct();
|
|
}
|
|
|
|
public function handle(): int
|
|
{
|
|
$post = Post::query()->find((string) $this->argument('post'));
|
|
|
|
if (! $post) {
|
|
$this->error('Post not found.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
if (! $this->isRetryable($post)) {
|
|
$this->error('Only failed or partially published posts can be retried.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$failedPlatforms = $this->failedPlatforms($post);
|
|
|
|
if ($failedPlatforms->isEmpty()) {
|
|
$this->warn('No failed enabled platforms matched this post.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
$this->table(
|
|
['Post platform ID', 'Platform', 'Account', 'Last error', 'Mode'],
|
|
$failedPlatforms->map(fn (PostPlatform $postPlatform): array => [
|
|
$postPlatform->id,
|
|
$postPlatform->platform->value,
|
|
$postPlatform->display_username ?? '—',
|
|
$postPlatform->error_message ?? '—',
|
|
$this->resumableContext($postPlatform->error_context) === null ? 'New' : 'Resume',
|
|
])->all(),
|
|
);
|
|
|
|
if (! $this->confirm('Queue publish attempts for these failed platforms?')) {
|
|
$this->info('Retry cancelled.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
$retryEntries = $this->prepareRetryEntries($post);
|
|
|
|
if ($retryEntries === []) {
|
|
$this->warn('The post changed while the command was running; nothing was retried.');
|
|
|
|
return self::FAILURE;
|
|
}
|
|
|
|
foreach ($retryEntries as $entry) {
|
|
if ($entry['platform'] === SocialPlatform::TikTok && PublishCheckpoint::tiktokPublishId($entry['error_context']) === null) {
|
|
$this->tiktokPhotoDerivativeCleaner->cleanup($entry['original_error_context'], $entry['id']);
|
|
}
|
|
|
|
$postPlatform = PostPlatform::query()->findOrFail($entry['id']);
|
|
PublishToSocialPlatform::dispatch($postPlatform);
|
|
}
|
|
|
|
Log::info('Failed post platforms queued for manual retry', [
|
|
'post_id' => $post->id,
|
|
'post_platform_ids' => array_column($retryEntries, 'id'),
|
|
]);
|
|
|
|
$this->info(count($retryEntries).' publish attempt(s) queued.');
|
|
|
|
return self::SUCCESS;
|
|
}
|
|
|
|
private function isRetryable(Post $post): bool
|
|
{
|
|
return in_array($post->status, [PostStatus::Failed, PostStatus::PartiallyPublished], true);
|
|
}
|
|
|
|
/**
|
|
* @return Collection<int, PostPlatform>
|
|
*/
|
|
private function failedPlatforms(Post $post, bool $lockForUpdate = false): Collection
|
|
{
|
|
return PostPlatform::query()
|
|
->with('socialAccount')
|
|
->where('post_id', $post->id)
|
|
->enabled()
|
|
->where('status', PlatformStatus::Failed)
|
|
->when($lockForUpdate, fn (Builder $query) => $query->lockForUpdate())
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* @return list<array{
|
|
* id: string,
|
|
* platform: SocialPlatform,
|
|
* error_context: array<string, mixed>|null,
|
|
* original_error_context: array<string, mixed>|null
|
|
* }>
|
|
*/
|
|
private function prepareRetryEntries(Post $post): array
|
|
{
|
|
return DB::transaction(function () use ($post): array {
|
|
$lockedPost = Post::query()->lockForUpdate()->find($post->id);
|
|
|
|
if (! $lockedPost || ! $this->isRetryable($lockedPost)) {
|
|
return [];
|
|
}
|
|
|
|
$platforms = $this->failedPlatforms($lockedPost, lockForUpdate: true);
|
|
|
|
if ($platforms->isEmpty()) {
|
|
return [];
|
|
}
|
|
|
|
$entries = [];
|
|
|
|
foreach ($platforms as $postPlatform) {
|
|
$nextContext = $this->resumableContext($postPlatform->error_context);
|
|
$entries[] = [
|
|
'id' => $postPlatform->id,
|
|
'platform' => $postPlatform->platform,
|
|
'error_context' => $nextContext,
|
|
'original_error_context' => $postPlatform->error_context,
|
|
];
|
|
|
|
$postPlatform->update([
|
|
'status' => PlatformStatus::Pending,
|
|
'platform_post_id' => null,
|
|
'platform_url' => null,
|
|
'error_message' => null,
|
|
'error_context' => $nextContext,
|
|
'published_at' => null,
|
|
]);
|
|
}
|
|
$lockedPost->update(['status' => PostStatus::Publishing]);
|
|
|
|
return $entries;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Keep in-flight checkpoints only. Confirmed remote failures must start over.
|
|
*
|
|
* @param array<string, mixed>|null $context
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private function resumableContext(?array $context): ?array
|
|
{
|
|
if (ErrorCategory::tryFromContext($context)?->isResumable() !== true) {
|
|
return null;
|
|
}
|
|
|
|
$kept = [];
|
|
$publishId = PublishCheckpoint::tiktokPublishId($context);
|
|
$workflow = PublishCheckpoint::instagramWorkflow($context);
|
|
|
|
if ($publishId !== null) {
|
|
$kept[PublishCheckpoint::TIKTOK_PUBLISH_ID] = $publishId;
|
|
$paths = PublishCheckpoint::tiktokDerivativePaths($context);
|
|
|
|
if ($paths !== []) {
|
|
$kept[PublishCheckpoint::TIKTOK_DERIVATIVE_PATHS] = $paths;
|
|
}
|
|
}
|
|
|
|
if ($workflow !== null) {
|
|
$kept[PublishCheckpoint::INSTAGRAM_WORKFLOW] = $workflow;
|
|
}
|
|
|
|
return $kept === [] ? null : $kept;
|
|
}
|
|
}
|