From d85fa48ea8abe2dd482a96c974eace059ca718eb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 11 Sep 2026 18:35:06 +0000 Subject: [PATCH] Retry Threads text posts when the container is not found Graph 24/4279009 on threads_publish is a known race for TEXT as well as media. Image, video, and carousel already recreated the container; text failed on the first hit and Nightwatch only stored the generic user message. Text now uses the same retry, and the exception message carries container_id plus Graph error_user_msg without changing the email copy. Co-authored-by: Paulo Castellano --- .../Social/SocialPublishException.php | 3 +- ...ThreadsMediaContainerNotFoundException.php | 16 +++- app/Services/Social/ThreadsPublisher.php | 14 ++-- .../Services/Social/ThreadsPublisherTest.php | 80 +++++++++++++++++++ .../Social/SocialPublishExceptionTest.php | 11 +++ ...adsMediaContainerNotFoundExceptionTest.php | 22 +++++ 6 files changed, 137 insertions(+), 9 deletions(-) diff --git a/app/Exceptions/Social/SocialPublishException.php b/app/Exceptions/Social/SocialPublishException.php index 70367e0ab..544c4e412 100644 --- a/app/Exceptions/Social/SocialPublishException.php +++ b/app/Exceptions/Social/SocialPublishException.php @@ -14,8 +14,9 @@ public function __construct( public readonly ErrorCategory $category, public readonly ?string $platformErrorCode = null, public readonly ?string $rawResponse = null, + ?string $message = null, ) { - parent::__construct($userMessage); + parent::__construct($message ?? $userMessage); } /** diff --git a/app/Exceptions/Social/ThreadsMediaContainerNotFoundException.php b/app/Exceptions/Social/ThreadsMediaContainerNotFoundException.php index f94140bf3..567cd49c5 100644 --- a/app/Exceptions/Social/ThreadsMediaContainerNotFoundException.php +++ b/app/Exceptions/Social/ThreadsMediaContainerNotFoundException.php @@ -19,14 +19,26 @@ public static function matches(Response $response): bool && $response->json('error.error_subcode') === self::ERROR_SUBCODE; } - public static function fromApiResponse(mixed $response): static + public static function fromApiResponse(mixed $response, ?string $containerId = null): static { /** @var Response $response */ + $payload = $response->json(); + $errorUserMsg = trim((string) data_get($payload, 'error.error_user_msg', '')); + $errorMessage = trim((string) data_get($payload, 'error.message', '')); + $userMessage = 'Threads could not find the processed media. Please try again.'; + $detail = collect([ + is_string($containerId) && $containerId !== '' ? "container_id={$containerId}" : null, + 'code='.self::ERROR_CODE, + 'error_subcode='.self::ERROR_SUBCODE, + $errorUserMsg !== '' ? "error_user_msg={$errorUserMsg}" : ($errorMessage !== '' ? "message={$errorMessage}" : null), + ])->filter()->implode(', '); + return new self( - userMessage: 'Threads could not find the processed media. Please try again.', + userMessage: $userMessage, category: ErrorCategory::ServerError, platformErrorCode: (string) self::ERROR_CODE, rawResponse: $response->body(), + message: $detail !== '' ? "{$userMessage} ({$detail})" : $userMessage, ); } } diff --git a/app/Services/Social/ThreadsPublisher.php b/app/Services/Social/ThreadsPublisher.php index 8585cfcad..c352c9443 100644 --- a/app/Services/Social/ThreadsPublisher.php +++ b/app/Services/Social/ThreadsPublisher.php @@ -57,7 +57,9 @@ public function publish(PostPlatform $postPlatform): array ); } - return $this->publishTextPost($userId, $accessToken, $content); + return $this->publishWithRetry( + fn (): array => $this->publishTextPost($userId, $accessToken, $content), + ); } $firstMedia = $media->first(); @@ -66,18 +68,18 @@ public function publish(PostPlatform $postPlatform): array // Single media if ($media->count() === 1) { if ($isVideo) { - return $this->publishMediaWithRetry( + return $this->publishWithRetry( fn (): array => $this->publishVideoPost($userId, $accessToken, $content, $firstMedia), ); } - return $this->publishMediaWithRetry( + return $this->publishWithRetry( fn (): array => $this->publishImagePost($userId, $accessToken, $content, $firstMedia), ); } // Multiple media - carousel - return $this->publishMediaWithRetry( + return $this->publishWithRetry( fn (): array => $this->publishCarousel($userId, $accessToken, $content, $media), ); } @@ -280,7 +282,7 @@ private function publishCarousel(string $userId, string $accessToken, ?string $c * @param callable(): array{id: string, url: ?string} $publish * @return array{id: string, url: ?string} */ - private function publishMediaWithRetry(callable $publish): array + private function publishWithRetry(callable $publish): array { for ($attempt = 1; $attempt <= self::MEDIA_PUBLICATION_MAX_ATTEMPTS; $attempt++) { try { @@ -313,7 +315,7 @@ private function publishContainer(string $userId, string $accessToken, string $c if ($publishResponse->failed()) { if (ThreadsMediaContainerNotFoundException::matches($publishResponse)) { - throw ThreadsMediaContainerNotFoundException::fromApiResponse($publishResponse); + throw ThreadsMediaContainerNotFoundException::fromApiResponse($publishResponse, $containerId); } Log::error('Threads publish failed', [ diff --git a/tests/Feature/Services/Social/ThreadsPublisherTest.php b/tests/Feature/Services/Social/ThreadsPublisherTest.php index d0a2ce778..b5ccd016a 100644 --- a/tests/Feature/Services/Social/ThreadsPublisherTest.php +++ b/tests/Feature/Services/Social/ThreadsPublisherTest.php @@ -172,6 +172,86 @@ Log::shouldNotHaveReceived('error'); }); +test('threads publisher recreates a missing text container before retrying publication', function () { + Log::spy(); + + $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_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, + 'error_user_title' => 'Media Not Found', + 'error_user_msg' => 'The media with id container-1 cannot be found.', + ], + ], 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); + + Log::shouldHaveReceived('warning')->once(); + Log::shouldNotHaveReceived('error'); +}); + +test('threads publisher stops after three missing text containers', function () { + $publicationAttempts = 0; + + Http::fake(function ($request) use (&$publicationAttempts) { + if (str_ends_with($request->url(), '/123456789/threads')) { + return Http::response(['id' => 'container-text'], 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, + 'error_user_msg' => 'The media with id container-text cannot be found.', + ], + ], 400); + } + + return Http::response([], 500); + }); + + expect(fn () => $this->publisher->publish($this->postPlatform)) + ->toThrow(function (ThreadsMediaContainerNotFoundException $exception): void { + expect($exception->userMessage)->toBe('Threads could not find the processed media. Please try again.') + ->and($exception->getMessage())->toContain('container_id=container-text') + ->and($exception->getMessage())->toContain('error_user_msg=The media with id container-text cannot be found.'); + }); + + expect($publicationAttempts)->toBe(3); +}); + test('threads publisher does not retry a missing media response from container creation', function () { $this->post->update([ 'media' => [[ diff --git a/tests/Unit/Exceptions/Social/SocialPublishExceptionTest.php b/tests/Unit/Exceptions/Social/SocialPublishExceptionTest.php index b4e345073..0ff6a7018 100644 --- a/tests/Unit/Exceptions/Social/SocialPublishExceptionTest.php +++ b/tests/Unit/Exceptions/Social/SocialPublishExceptionTest.php @@ -92,6 +92,17 @@ public function platform(): string expect($exception->getMessage())->toBe('Rate limit exceeded.'); }); +test('exception message can carry Nightwatch detail without changing userMessage', function () { + $exception = new TestPlatformException( + userMessage: 'Rate limit exceeded.', + category: ErrorCategory::RateLimit, + message: 'Rate limit exceeded. (code=4)', + ); + + expect($exception->userMessage)->toBe('Rate limit exceeded.') + ->and($exception->getMessage())->toBe('Rate limit exceeded. (code=4)'); +}); + test('fromApiResponse creates exception from response', function () { $exception = TestPlatformException::fromApiResponse(['error' => 'test']); diff --git a/tests/Unit/Exceptions/Social/ThreadsMediaContainerNotFoundExceptionTest.php b/tests/Unit/Exceptions/Social/ThreadsMediaContainerNotFoundExceptionTest.php index ee1fe25c1..c8cfbe346 100644 --- a/tests/Unit/Exceptions/Social/ThreadsMediaContainerNotFoundExceptionTest.php +++ b/tests/Unit/Exceptions/Social/ThreadsMediaContainerNotFoundExceptionTest.php @@ -27,9 +27,31 @@ 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->getMessage())->toBe( + 'Threads could not find the processed media. Please try again. (code=24, error_subcode=4279009, error_user_msg=The media with id 17979429000118151 cannot be found.)' + ) ->and($exception->rawResponse)->toContain('4279009'); }); +test('fromApiResponse puts the container id on the Nightwatch message', function () { + $response = Http::response([ + 'error' => [ + 'message' => 'The requested resource does not exist', + 'type' => 'OAuthException', + 'code' => 24, + 'error_subcode' => 4279009, + 'error_user_msg' => 'The media with id 17979429000118151 cannot be found.', + ], + ], 400); + + $fakeResponse = Http::fake(['*' => $response])->post('https://graph.threads.net/test'); + $exception = ThreadsMediaContainerNotFoundException::fromApiResponse($fakeResponse, '17979429000118151'); + + expect($exception->userMessage)->toBe('Threads could not find the processed media. Please try again.') + ->and($exception->getMessage())->toContain('container_id=17979429000118151') + ->and($exception->getMessage())->toContain('error_user_msg=The media with id 17979429000118151 cannot be found.'); +}); + test('does not match code 24 without the missing media subcode', function () { $response = Http::response([ 'error' => [