From 9fb4a0263b8fc268b8e5045f3a316fe1d2fe4ab8 Mon Sep 17 00:00:00 2001 From: Grigory Frolov <2168057+gynsus@users.noreply.github.com> Date: Fri, 28 Aug 2026 09:10:40 +0300 Subject: [PATCH] fix: isolate per-platform metric failures and send bodyless Bluesky refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the same outage. bsky.social started rejecting refreshSession calls that carry a body — a data-less post() still sends an empty JSON body — so the scheduled refresh failed every cycle and the account limped along on the credential re-auth fallback, leaving windows with a dead access token. During those windows the analytics service threw out of PostMetricsFetcher's match and a single provider's exception turned the whole get-post-metrics aggregate into a 500 for every post. The refresh now uses send('POST') with no body, and forPlatform wraps each provider in a try/catch that logs which platform failed and returns an unsupported/error entry so the other platforms still report. --- app/Services/Post/PostMetricsFetcher.php | 18 +++++++- app/Services/Social/ConnectionVerifier.php | 5 ++- .../Services/Post/PostMetricsFetcherTest.php | 43 +++++++++++++++++++ .../Social/BlueskyRefreshSessionTest.php | 38 ++++++++++++++++ 4 files changed, 101 insertions(+), 3 deletions(-) create mode 100644 tests/Feature/Services/Post/PostMetricsFetcherTest.php create mode 100644 tests/Feature/Services/Social/BlueskyRefreshSessionTest.php diff --git a/app/Services/Post/PostMetricsFetcher.php b/app/Services/Post/PostMetricsFetcher.php index b68e3305b..e36a109ab 100644 --- a/app/Services/Post/PostMetricsFetcher.php +++ b/app/Services/Post/PostMetricsFetcher.php @@ -20,6 +20,7 @@ use App\Services\Social\YouTubeAnalytics; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Cache; +use Illuminate\Support\Facades\Log; /** * Fetches per-platform post metrics. Used by the web controller, the REST @@ -62,7 +63,8 @@ public function forPlatform(PostPlatform $postPlatform): array return ['unsupported' => true, 'reason' => 'not_published']; } - return Cache::remember("post_metrics:{$postPlatform->id}", 300, fn () => match ($postPlatform->platform) { + try { + return Cache::remember("post_metrics:{$postPlatform->id}", 300, fn () => match ($postPlatform->platform) { Platform::X => app(XAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Bluesky => app(BlueskyAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Mastodon => app(MastodonAnalytics::class)->fetchPostMetrics($postPlatform), @@ -75,6 +77,18 @@ public function forPlatform(PostPlatform $postPlatform): array Platform::YouTube => app(YouTubeAnalytics::class)->fetchPostMetrics($postPlatform), Platform::Pinterest => app(PinterestAnalytics::class)->fetchPostMetrics($postPlatform), default => ['unsupported' => true, 'reason' => 'platform_not_supported'], - }); + }); + } catch (\Throwable $e) { + // Один упавший провайдер (протухший токен, квота, сетевой сбой) не + // должен ронять метрики всего поста — остальные платформы отдаются. + Log::warning('Post metrics fetch failed for platform', [ + 'post_platform_id' => $postPlatform->id, + 'platform' => $postPlatform->platform->value, + 'exception' => $e::class, + 'error' => $e->getMessage(), + ]); + + return ['unsupported' => true, 'reason' => 'error']; + } } } diff --git a/app/Services/Social/ConnectionVerifier.php b/app/Services/Social/ConnectionVerifier.php index 7c38c91f0..c48cbeac8 100644 --- a/app/Services/Social/ConnectionVerifier.php +++ b/app/Services/Social/ConnectionVerifier.php @@ -298,8 +298,11 @@ private function refreshBlueskyToken(SocialAccount $account): void $client = TokenRefreshClient::for(Platform::Bluesky); try { + // refreshSession must be POSTed with NO body: `post()` without data + // still sends an empty JSON body, and bsky.social now rejects it + // with "A request body was provided when none was expected". $response = $client->send(fn () => $this->refreshHttp()->withToken($account->refresh_token) - ->post("{$service}/xrpc/".BlueskyLexicon::REFRESH_SESSION)); + ->send('POST', "{$service}/xrpc/".BlueskyLexicon::REFRESH_SESSION)); $data = $response->json(); $account->update([ diff --git a/tests/Feature/Services/Post/PostMetricsFetcherTest.php b/tests/Feature/Services/Post/PostMetricsFetcherTest.php new file mode 100644 index 000000000..651cbca77 --- /dev/null +++ b/tests/Feature/Services/Post/PostMetricsFetcherTest.php @@ -0,0 +1,43 @@ +user = User::factory()->create(); + $this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]); + $this->post = Post::factory()->create([ + 'workspace_id' => $this->workspace->id, + 'user_id' => $this->user->id, + 'content' => 'x', + ]); +}); + +test('a provider exception degrades to an unsupported entry instead of failing the aggregate', function () { + $account = SocialAccount::factory()->x()->create(['workspace_id' => $this->workspace->id]); + $row = PostPlatform::factory()->create([ + 'post_id' => $this->post->id, + 'social_account_id' => $account->id, + 'platform' => Platform::X, + 'content_type' => ContentType::XPost, + 'status' => \App\Enums\PostPlatform\Status::Published, + 'platform_post_id' => '123', + ]); + + $this->mock(XAnalytics::class) + ->shouldReceive('fetchPostMetrics') + ->andThrow(new RuntimeException('token expired mid-flight')); + + $metrics = app(PostMetricsFetcher::class)->forPlatform($row->fresh()); + + expect($metrics)->toBe(['unsupported' => true, 'reason' => 'error']); +}); diff --git a/tests/Feature/Services/Social/BlueskyRefreshSessionTest.php b/tests/Feature/Services/Social/BlueskyRefreshSessionTest.php new file mode 100644 index 000000000..d789346f7 --- /dev/null +++ b/tests/Feature/Services/Social/BlueskyRefreshSessionTest.php @@ -0,0 +1,38 @@ +create(); + $workspace = Workspace::factory()->create(['user_id' => $user->id]); + $account = SocialAccount::factory()->bluesky()->create([ + 'workspace_id' => $workspace->id, + 'refresh_token' => 'refresh-jwt', + ]); + + $service = config('trypost.platforms.bluesky.default_service'); + + Http::fake([ + "{$service}/xrpc/com.atproto.server.refreshSession" => Http::response([ + 'accessJwt' => 'new-access', + 'refreshJwt' => 'new-refresh', + ], 200), + ]); + + app(ConnectionVerifier::class)->refreshToken($account); + + // bsky.social rejects refreshSession when any body is present — even the + // empty JSON object/array a data-less post() would send. + Http::assertSent(function ($request) { + return str_contains($request->url(), 'refreshSession') + && $request->body() === ''; + }); + + expect($account->fresh()->access_token)->toBe('new-access'); +});