From 2f6c006bfb1f8db2b4cc60b8da94eac3cd1207d8 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Thu, 13 Aug 2026 12:18:19 -0300 Subject: [PATCH] Fix transient Threads media container failures (#280) * fix: retry missing Threads media containers * test: harden Threads media retry flow * refactor: isolate Threads missing container error --- ...ThreadsMediaContainerNotFoundException.php | 32 ++ app/Services/Social/ThreadsPublisher.php | 61 +++- .../Services/Social/ThreadsPublisherTest.php | 334 ++++++++++++++++++ ...adsMediaContainerNotFoundExceptionTest.php | 59 ++++ 4 files changed, 481 insertions(+), 5 deletions(-) create mode 100644 app/Exceptions/Social/ThreadsMediaContainerNotFoundException.php create mode 100644 tests/Unit/Exceptions/Social/ThreadsMediaContainerNotFoundExceptionTest.php diff --git a/app/Exceptions/Social/ThreadsMediaContainerNotFoundException.php b/app/Exceptions/Social/ThreadsMediaContainerNotFoundException.php new file mode 100644 index 00000000..f94140bf --- /dev/null +++ b/app/Exceptions/Social/ThreadsMediaContainerNotFoundException.php @@ -0,0 +1,32 @@ +status() === 400 + && $response->json('error.code') === self::ERROR_CODE + && $response->json('error.error_subcode') === self::ERROR_SUBCODE; + } + + public static function fromApiResponse(mixed $response): static + { + /** @var Response $response */ + return new self( + userMessage: 'Threads could not find the processed media. Please try again.', + category: ErrorCategory::ServerError, + platformErrorCode: (string) self::ERROR_CODE, + rawResponse: $response->body(), + ); + } +} diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index 593567ec..8585cfca 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -6,16 +6,24 @@ use App\Enums\SocialAccount\Platform; use App\Exceptions\Social\ErrorCategory; +use App\Exceptions\Social\ThreadsMediaContainerNotFoundException; use App\Exceptions\Social\ThreadsPublishException; use App\Models\PostPlatform; use App\Services\Social\Concerns\HasSocialHttpClient; use Illuminate\Http\Client\Response; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Sleep; class ThreadsPublisher { use HasSocialHttpClient; + private const int MEDIA_PUBLICATION_MAX_ATTEMPTS = 3; + + private const int MEDIA_PROCESSING_POLL_SECONDS = 3; + + private const int MEDIA_READY_GRACE_SECONDS = 2; + private string $baseUrl; public function __construct() @@ -58,14 +66,20 @@ public function publish(PostPlatform $postPlatform): array // Single media if ($media->count() === 1) { if ($isVideo) { - return $this->publishVideoPost($userId, $accessToken, $content, $firstMedia); + return $this->publishMediaWithRetry( + fn (): array => $this->publishVideoPost($userId, $accessToken, $content, $firstMedia), + ); } - return $this->publishImagePost($userId, $accessToken, $content, $firstMedia); + return $this->publishMediaWithRetry( + fn (): array => $this->publishImagePost($userId, $accessToken, $content, $firstMedia), + ); } // Multiple media - carousel - return $this->publishCarousel($userId, $accessToken, $content, $media); + return $this->publishMediaWithRetry( + fn (): array => $this->publishCarousel($userId, $accessToken, $content, $media), + ); } private function publishTextPost(string $userId, string $accessToken, string $content): array @@ -256,10 +270,40 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c ); } + $this->waitForMediaProcessing($carouselId, $accessToken); + // Step 3: Publish carousel return $this->publishContainer($userId, $accessToken, $carouselId); } + /** + * @param callable(): array{id: string, url: ?string} $publish + * @return array{id: string, url: ?string} + */ + private function publishMediaWithRetry(callable $publish): array + { + for ($attempt = 1; $attempt <= self::MEDIA_PUBLICATION_MAX_ATTEMPTS; $attempt++) { + try { + return $publish(); + } catch (ThreadsMediaContainerNotFoundException $exception) { + if ($attempt === self::MEDIA_PUBLICATION_MAX_ATTEMPTS) { + throw $exception; + } + + Log::warning('Threads media container was not found; recreating publication flow', [ + 'attempt' => $attempt, + 'max_attempts' => self::MEDIA_PUBLICATION_MAX_ATTEMPTS, + ...$exception->context(), + ]); + } + } + + throw new ThreadsPublishException( + userMessage: 'Threads did not accept the post. Please publish again.', + category: ErrorCategory::ServerError, + ); + } + private function publishContainer(string $userId, string $accessToken, string $containerId): array { $publishResponse = $this->socialHttp()->post("{$this->baseUrl}/{$userId}/threads_publish", [ @@ -268,10 +312,15 @@ private function publishContainer(string $userId, string $accessToken, string $c ]); if ($publishResponse->failed()) { + if (ThreadsMediaContainerNotFoundException::matches($publishResponse)) { + throw ThreadsMediaContainerNotFoundException::fromApiResponse($publishResponse); + } + Log::error('Threads publish failed', [ 'status' => $publishResponse->status(), 'body' => $this->redactResponseBody($publishResponse->body()), ]); + $this->handleApiError($publishResponse); } @@ -312,7 +361,7 @@ private function waitForMediaProcessing(string $containerId, string $accessToken 'attempt' => $i, 'body' => $this->redactResponseBody($statusResponse->body()), ]); - sleep(3); + Sleep::for(self::MEDIA_PROCESSING_POLL_SECONDS)->seconds(); continue; } @@ -321,6 +370,8 @@ private function waitForMediaProcessing(string $containerId, string $accessToken $status = data_get($data, 'status', 'UNKNOWN'); if ($status === 'FINISHED') { + Sleep::for(self::MEDIA_READY_GRACE_SECONDS)->seconds(); + return; } @@ -332,7 +383,7 @@ private function waitForMediaProcessing(string $containerId, string $accessToken ); } - sleep(3); + Sleep::for(self::MEDIA_PROCESSING_POLL_SECONDS)->seconds(); } Log::warning('Threads media processing timeout', ['container_id' => $containerId]); diff --git a/tests/Feature/Services/Social/ThreadsPublisherTest.php b/tests/Feature/Services/Social/ThreadsPublisherTest.php index f3480026..7bf19e3f 100644 --- a/tests/Feature/Services/Social/ThreadsPublisherTest.php +++ b/tests/Feature/Services/Social/ThreadsPublisherTest.php @@ -4,6 +4,8 @@ use App\Enums\PostPlatform\ContentType; use App\Enums\SocialAccount\Platform; +use App\Exceptions\Social\ThreadsMediaContainerNotFoundException; +use App\Exceptions\Social\ThreadsPublishException; use App\Exceptions\TokenExpiredException; use App\Models\Post; use App\Models\PostPlatform; @@ -12,8 +14,12 @@ use App\Models\Workspace; use App\Services\Social\ThreadsPublisher; use Illuminate\Support\Facades\Http; +use Illuminate\Support\Facades\Log; +use Illuminate\Support\Sleep; beforeEach(function () { + Sleep::fake(); + $this->user = User::factory()->create(); $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); @@ -103,6 +109,197 @@ }); }); +test('threads publisher recreates a missing image container before retrying publication', function () { + Log::spy(); + + $this->post->update([ + 'media' => [[ + 'id' => 'test-media-id', + 'path' => 'media/2026-01/test-image.jpg', + 'url' => 'https://example.com/media/2026-01/test-image.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'test.jpg', + ]], + ]); + + $containerCreations = 0; + $publicationAttempts = 0; + + Http::fake(function ($request) use (&$containerCreations, &$publicationAttempts) { + if (str_ends_with($request->url(), '/123456789/threads')) { + $containerCreations++; + + return Http::response(['id' => "container-{$containerCreations}"], 200); + } + + if (str_contains($request->url(), '/container-')) { + return Http::response(['status' => 'FINISHED'], 200); + } + + if (str_ends_with($request->url(), '/123456789/threads_publish')) { + $publicationAttempts++; + + if ($publicationAttempts === 1) { + return Http::response([ + 'error' => [ + 'message' => 'The requested resource does not exist', + 'code' => 24, + 'error_subcode' => 4279009, + ], + ], 400); + } + + return Http::response(['id' => 'post-after-retry'], 200); + } + + return Http::response([ + 'permalink' => 'https://www.threads.net/@testuser/post/RETRY', + ], 200); + }); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('post-after-retry') + ->and($containerCreations)->toBe(2) + ->and($publicationAttempts)->toBe(2); + + Sleep::assertSequence([ + Sleep::for(2)->seconds(), + Sleep::for(2)->seconds(), + ]); + + Log::shouldHaveReceived('warning')->once(); + Log::shouldNotHaveReceived('error'); +}); + +test('threads publisher does not retry a missing media response from container creation', function () { + $this->post->update([ + 'media' => [[ + 'id' => 'test-media-id', + 'path' => 'media/2026-01/test-image.jpg', + 'url' => 'https://example.com/media/2026-01/test-image.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'test.jpg', + ]], + ]); + + $containerCreations = 0; + + Http::fake(function ($request) use (&$containerCreations) { + $containerCreations++; + + return Http::response([ + 'error' => [ + 'message' => 'The requested resource does not exist', + 'code' => 24, + 'error_subcode' => 4279009, + ], + ], 400); + }); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(ThreadsPublishException::class); + + expect($containerCreations)->toBe(1); +}); + +test('threads publisher stops after three missing media containers', function () { + $this->post->update([ + 'media' => [[ + 'id' => 'test-media-id', + 'path' => 'media/2026-01/test-image.jpg', + 'url' => 'https://example.com/media/2026-01/test-image.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'test.jpg', + ]], + ]); + + $containerCreations = 0; + $publicationAttempts = 0; + + Http::fake(function ($request) use (&$containerCreations, &$publicationAttempts) { + if (str_ends_with($request->url(), '/123456789/threads')) { + $containerCreations++; + + return Http::response(['id' => "container-{$containerCreations}"], 200); + } + + if (str_contains($request->url(), '/container-')) { + return Http::response(['status' => 'FINISHED'], 200); + } + + if (str_ends_with($request->url(), '/123456789/threads_publish')) { + $publicationAttempts++; + + return Http::response([ + 'error' => [ + 'message' => 'The requested resource does not exist', + 'code' => 24, + 'error_subcode' => 4279009, + ], + ], 400); + } + + return Http::response([], 500); + }); + + try { + $this->publisher->publish($this->postPlatform); + $this->fail('Expected ThreadsPublishException was not thrown.'); + } catch (ThreadsPublishException $exception) { + expect($exception)->toBeInstanceOf(ThreadsMediaContainerNotFoundException::class); + } + + expect($containerCreations)->toBe(3) + ->and($publicationAttempts)->toBe(3); +}); + +test('threads publisher does not retry unrelated client errors', function () { + $this->post->update([ + 'media' => [[ + 'id' => 'test-media-id', + 'path' => 'media/2026-01/test-image.jpg', + 'url' => 'https://example.com/media/2026-01/test-image.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'test.jpg', + ]], + ]); + + $containerCreations = 0; + $publicationAttempts = 0; + + Http::fake(function ($request) use (&$containerCreations, &$publicationAttempts) { + if (str_ends_with($request->url(), '/123456789/threads')) { + $containerCreations++; + + return Http::response(['id' => 'container-1'], 200); + } + + if (str_contains($request->url(), '/container-1')) { + return Http::response(['status' => 'FINISHED'], 200); + } + + if (str_ends_with($request->url(), '/123456789/threads_publish')) { + $publicationAttempts++; + + return Http::response([ + 'error' => [ + 'message' => 'Invalid parameter', + 'code' => 100, + ], + ], 400); + } + + return Http::response([], 500); + }); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(ThreadsPublishException::class, 'Invalid parameter'); + + expect($containerCreations)->toBe(1) + ->and($publicationAttempts)->toBe(1); +}); + test('threads publisher can publish video post', function () { $this->post->update([ 'media' => [ @@ -178,6 +375,143 @@ }); }); +test('threads publisher waits for the final carousel container before publishing', function () { + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-1', + 'path' => 'media/2026-01/test-image-1.jpg', + 'url' => 'https://example.com/media/2026-01/test-image-1.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'test-1.jpg', + ], + [ + 'id' => 'test-media-2', + 'path' => 'media/2026-01/test-image-2.jpg', + 'url' => 'https://example.com/media/2026-01/test-image-2.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'test-2.jpg', + ], + ], + ]); + + $containerCreations = 0; + $requestOrder = []; + + Http::fake(function ($request) use (&$containerCreations, &$requestOrder) { + if (str_ends_with($request->url(), '/123456789/threads')) { + $containerCreations++; + $containerId = $containerCreations <= 2 + ? "child-{$containerCreations}" + : 'carousel-final'; + $requestOrder[] = "create:{$containerId}"; + + return Http::response(['id' => $containerId], 200); + } + + if (str_contains($request->url(), '/child-')) { + $requestOrder[] = 'status:child'; + + return Http::response(['status' => 'FINISHED'], 200); + } + + if (str_contains($request->url(), '/carousel-final')) { + $requestOrder[] = 'status:carousel-final'; + + return Http::response(['status' => 'FINISHED'], 200); + } + + if (str_ends_with($request->url(), '/123456789/threads_publish')) { + $requestOrder[] = 'publish'; + + return Http::response(['id' => 'carousel-post'], 200); + } + + return Http::response(['permalink' => 'https://www.threads.net/carousel'], 200); + }); + + $this->publisher->publish($this->postPlatform); + + expect($requestOrder) + ->toContain('status:carousel-final') + ->toContain('publish'); + + $finalStatusIndex = array_search('status:carousel-final', $requestOrder, true); + $publishIndex = array_search('publish', $requestOrder, true); + + expect($finalStatusIndex)->toBeInt() + ->and($publishIndex)->toBeInt() + ->and($finalStatusIndex)->toBeLessThan($publishIndex); +}); + +test('threads publisher recreates the complete carousel after a missing final container', function () { + $this->post->update([ + 'media' => [ + [ + 'id' => 'test-media-1', + 'path' => 'media/2026-01/test-image-1.jpg', + 'url' => 'https://example.com/media/2026-01/test-image-1.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'test-1.jpg', + ], + [ + 'id' => 'test-media-2', + 'path' => 'media/2026-01/test-image-2.jpg', + 'url' => 'https://example.com/media/2026-01/test-image-2.jpg', + 'mime_type' => 'image/jpeg', + 'original_filename' => 'test-2.jpg', + ], + ], + ]); + + $childCreations = 0; + $carouselCreations = 0; + $publicationAttempts = 0; + + Http::fake(function ($request) use (&$childCreations, &$carouselCreations, &$publicationAttempts) { + if (str_ends_with($request->url(), '/123456789/threads')) { + if ($request['media_type'] === 'CAROUSEL') { + $carouselCreations++; + + return Http::response(['id' => "carousel-{$carouselCreations}"], 200); + } + + $childCreations++; + + return Http::response(['id' => "child-{$childCreations}"], 200); + } + + if (str_contains($request->url(), '/child-') || str_contains($request->url(), '/carousel-')) { + return Http::response(['status' => 'FINISHED'], 200); + } + + if (str_ends_with($request->url(), '/123456789/threads_publish')) { + $publicationAttempts++; + + if ($publicationAttempts === 1) { + return Http::response([ + 'error' => [ + 'message' => 'The requested resource does not exist', + 'code' => 24, + 'error_subcode' => 4279009, + ], + ], 400); + } + + return Http::response(['id' => 'carousel-post'], 200); + } + + return Http::response(['permalink' => 'https://www.threads.net/carousel'], 200); + }); + + $result = $this->publisher->publish($this->postPlatform); + + expect($result['id'])->toBe('carousel-post') + ->and($childCreations)->toBe(4) + ->and($carouselCreations)->toBe(2) + ->and($publicationAttempts)->toBe(2); +}); + test('threads publisher throws exception on api error', function () { Http::fake([ 'https://graph.threads.net/v1.0/123456789/threads' => Http::response([ diff --git a/tests/Unit/Exceptions/Social/ThreadsMediaContainerNotFoundExceptionTest.php b/tests/Unit/Exceptions/Social/ThreadsMediaContainerNotFoundExceptionTest.php new file mode 100644 index 00000000..ee1fe25c --- /dev/null +++ b/tests/Unit/Exceptions/Social/ThreadsMediaContainerNotFoundExceptionTest.php @@ -0,0 +1,59 @@ + [ + 'message' => 'The requested resource does not exist', + 'type' => 'OAuthException', + 'code' => 24, + 'error_subcode' => 4279009, + 'error_user_title' => 'Media Not Found', + 'error_user_msg' => 'The media with id 17979429000118151 cannot be found.', + ], + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.threads.net/test'); + + expect(ThreadsMediaContainerNotFoundException::matches($fakeResponse))->toBeTrue(); + + $exception = ThreadsMediaContainerNotFoundException::fromApiResponse($fakeResponse); + + expect($exception->platformErrorCode)->toBe('24') + ->and($exception->category)->toBe(ErrorCategory::ServerError) + ->and($exception->userMessage)->toBe('Threads could not find the processed media. Please try again.') + ->and($exception->rawResponse)->toContain('4279009'); +}); + +test('does not match code 24 without the missing media subcode', function () { + $response = Http::response([ + 'error' => [ + 'message' => 'Another media error', + 'type' => 'OAuthException', + 'code' => 24, + 'error_subcode' => 1234567, + ], + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.threads.net/test'); + + expect(ThreadsMediaContainerNotFoundException::matches($fakeResponse))->toBeFalse(); +}); + +test('does not match the missing media payload outside an HTTP 400 response', function () { + $response = Http::response([ + 'error' => [ + 'code' => 24, + 'error_subcode' => 4279009, + ], + ], 500); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.threads.net/test'); + + expect(ThreadsMediaContainerNotFoundException::matches($fakeResponse))->toBeFalse(); +});