trypost/app/Console/Commands/RetryFailedPost.php
Paulo Castellano 4546425532
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 15:34:53 -03:00

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;
}
}