Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/Exceptions/Social/SocialPublishException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

/**
Expand Down
16 changes: 14 additions & 2 deletions app/Exceptions/Social/ThreadsMediaContainerNotFoundException.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
}
14 changes: 8 additions & 6 deletions app/Services/Social/ThreadsPublisher.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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),
);
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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', [
Expand Down
80 changes: 80 additions & 0 deletions tests/Feature/Services/Social/ThreadsPublisherTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [[
Expand Down
11 changes: 11 additions & 0 deletions tests/Unit/Exceptions/Social/SocialPublishExceptionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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']);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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' => [
Expand Down