trypost/app/Console/Commands/RetryFailedPost.php

203 lines
6.5 KiB
PHP
Raw Permalink Normal View History

Resume in-flight Instagram and TikTok publishes without duplicates (#281) * 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>
2026-08-16 18:34:53 +00:00
<?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;
}
}