From e1ac9f87f882514079016f5db4809bfc1116fd38 Mon Sep 17 00:00:00 2001 From: Paulo Castellano Date: Mon, 26 Jan 2026 10:48:20 -0300 Subject: [PATCH] fix: retry LinkedIn publish with token refresh when token is revoked --- app/Services/Social/LinkedInPagePublisher.php | 66 +++++++++++++++---- app/Services/Social/LinkedInPublisher.php | 65 ++++++++++++++---- .../Social/LinkedInPagePublisherTest.php | 6 +- .../Services/Social/LinkedInPublisherTest.php | 6 +- 4 files changed, 119 insertions(+), 24 deletions(-) diff --git a/app/Services/Social/LinkedInPagePublisher.php b/app/Services/Social/LinkedInPagePublisher.php index 2e60ecfd..4318f3f0 100644 --- a/app/Services/Social/LinkedInPagePublisher.php +++ b/app/Services/Social/LinkedInPagePublisher.php @@ -30,18 +30,23 @@ class LinkedInPagePublisher private string $accessToken; + private SocialAccount $account; + + private bool $hasRetried = false; + public function publish(PostPlatform $postPlatform): array { - $account = $postPlatform->socialAccount; + $this->account = $postPlatform->socialAccount; + $this->hasRetried = false; - if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshToken($account); - $account->refresh(); + if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) { + $this->refreshToken($this->account); + $this->account->refresh(); } - $this->accessToken = $account->access_token; + $this->accessToken = $this->account->access_token; - $organizationId = $account->meta['organization_id'] ?? null; + $organizationId = $this->account->meta['organization_id'] ?? null; if (! $organizationId) { throw new \Exception('LinkedIn Page organization ID not configured'); @@ -50,11 +55,50 @@ public function publish(PostPlatform $postPlatform): array $organizationUrn = "urn:li:organization:{$organizationId}"; $contentType = $postPlatform->content_type; - return match ($contentType) { - ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $postPlatform->content, $postPlatform->media, $account), - ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $postPlatform->content, $postPlatform->media, $account), - default => throw new \Exception("Unsupported LinkedIn Page content type: {$contentType?->value}"), - }; + try { + return match ($contentType) { + ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $postPlatform->content, $postPlatform->media, $this->account), + ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $postPlatform->content, $postPlatform->media, $this->account), + default => throw new \Exception("Unsupported LinkedIn Page content type: {$contentType?->value}"), + }; + } catch (TokenExpiredException $e) { + return $this->retryWithRefresh($postPlatform, $e); + } + } + + private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredException $originalException): array + { + if ($this->hasRetried) { + throw $originalException; + } + + $this->hasRetried = true; + + Log::info('LinkedIn Page token rejected, attempting refresh and retry', [ + 'account_id' => $this->account->id, + ]); + + try { + $this->refreshToken($this->account); + $this->account->refresh(); + $this->accessToken = $this->account->access_token; + + $organizationId = $this->account->meta['organization_id'] ?? null; + $organizationUrn = "urn:li:organization:{$organizationId}"; + $contentType = $postPlatform->content_type; + + return match ($contentType) { + ContentType::LinkedInPageCarousel => $this->publishCarousel($organizationUrn, $postPlatform->content, $postPlatform->media, $this->account), + ContentType::LinkedInPagePost => $this->publishPost($organizationUrn, $postPlatform->content, $postPlatform->media, $this->account), + default => throw new \Exception("Unsupported LinkedIn Page content type: {$contentType?->value}"), + }; + } catch (\Throwable $e) { + Log::error('LinkedIn Page refresh failed during retry', [ + 'account_id' => $this->account->id, + 'error' => $e->getMessage(), + ]); + throw $originalException; + } } private function publishPost(string $organizationUrn, string $content, $media, $account): array diff --git a/app/Services/Social/LinkedInPublisher.php b/app/Services/Social/LinkedInPublisher.php index 4ac701e3..6da52865 100644 --- a/app/Services/Social/LinkedInPublisher.php +++ b/app/Services/Social/LinkedInPublisher.php @@ -30,25 +30,68 @@ class LinkedInPublisher private string $accessToken; + private SocialAccount $account; + + private bool $hasRetried = false; + public function publish(PostPlatform $postPlatform): array { - $account = $postPlatform->socialAccount; + $this->account = $postPlatform->socialAccount; + $this->hasRetried = false; - if ($account->is_token_expired || $account->is_token_expiring_soon) { - $this->refreshToken($account); - $account->refresh(); + if ($this->account->is_token_expired || $this->account->is_token_expiring_soon) { + $this->refreshToken($this->account); + $this->account->refresh(); } - $this->accessToken = $account->access_token; + $this->accessToken = $this->account->access_token; - $personUrn = "urn:li:person:{$account->platform_user_id}"; + $personUrn = "urn:li:person:{$this->account->platform_user_id}"; $contentType = $postPlatform->content_type; - return match ($contentType) { - ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $postPlatform->content, $postPlatform->media), - ContentType::LinkedInPost => $this->publishPost($personUrn, $postPlatform->content, $postPlatform->media), - default => throw new \Exception("Unsupported LinkedIn content type: {$contentType?->value}"), - }; + try { + return match ($contentType) { + ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $postPlatform->content, $postPlatform->media), + ContentType::LinkedInPost => $this->publishPost($personUrn, $postPlatform->content, $postPlatform->media), + default => throw new \Exception("Unsupported LinkedIn content type: {$contentType?->value}"), + }; + } catch (TokenExpiredException $e) { + return $this->retryWithRefresh($postPlatform, $e); + } + } + + private function retryWithRefresh(PostPlatform $postPlatform, TokenExpiredException $originalException): array + { + if ($this->hasRetried) { + throw $originalException; + } + + $this->hasRetried = true; + + Log::info('LinkedIn token rejected, attempting refresh and retry', [ + 'account_id' => $this->account->id, + ]); + + try { + $this->refreshToken($this->account); + $this->account->refresh(); + $this->accessToken = $this->account->access_token; + + $personUrn = "urn:li:person:{$this->account->platform_user_id}"; + $contentType = $postPlatform->content_type; + + return match ($contentType) { + ContentType::LinkedInCarousel => $this->publishCarousel($personUrn, $postPlatform->content, $postPlatform->media), + ContentType::LinkedInPost => $this->publishPost($personUrn, $postPlatform->content, $postPlatform->media), + default => throw new \Exception("Unsupported LinkedIn content type: {$contentType?->value}"), + }; + } catch (\Throwable $e) { + Log::error('LinkedIn refresh failed during retry', [ + 'account_id' => $this->account->id, + 'error' => $e->getMessage(), + ]); + throw $originalException; + } } private function publishPost(string $personUrn, string $content, $media): array diff --git a/tests/Unit/Services/Social/LinkedInPagePublisherTest.php b/tests/Unit/Services/Social/LinkedInPagePublisherTest.php index 81d05cc4..518279d7 100644 --- a/tests/Unit/Services/Social/LinkedInPagePublisherTest.php +++ b/tests/Unit/Services/Social/LinkedInPagePublisherTest.php @@ -115,12 +115,16 @@ ->toThrow(Exception::class); }); -test('linkedin page publisher throws token expired exception on auth error', function () { +test('linkedin page publisher throws token expired exception on auth error after retry', function () { Http::fake([ 'https://api.linkedin.com/rest/posts' => Http::response([ 'code' => 'EXPIRED_ACCESS_TOKEN', 'message' => 'The token used in the request has expired', ], 401), + 'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([ + 'error' => 'invalid_grant', + 'error_description' => 'The refresh token is invalid', + ], 400), ]); expect(fn () => $this->publisher->publish($this->postPlatform)) diff --git a/tests/Unit/Services/Social/LinkedInPublisherTest.php b/tests/Unit/Services/Social/LinkedInPublisherTest.php index 5e29137d..9c56185a 100644 --- a/tests/Unit/Services/Social/LinkedInPublisherTest.php +++ b/tests/Unit/Services/Social/LinkedInPublisherTest.php @@ -89,12 +89,16 @@ ->toThrow(Exception::class); }); -test('linkedin publisher throws token expired exception on auth error', function () { +test('linkedin publisher throws token expired exception on auth error after retry', function () { Http::fake([ 'https://api.linkedin.com/rest/posts' => Http::response([ 'code' => 'EXPIRED_ACCESS_TOKEN', 'message' => 'The token used in the request has expired', ], 401), + 'https://www.linkedin.com/oauth/v2/accessToken' => Http::response([ + 'error' => 'invalid_grant', + 'error_description' => 'The refresh token is invalid', + ], 400), ]); expect(fn () => $this->publisher->publish($this->postPlatform))