From a1d7d267703fd3af5470129acfecfcaff0a0d382 Mon Sep 17 00:00:00 2001 From: DanMat Date: Mon, 7 Sep 2026 16:41:01 -0400 Subject: [PATCH] Surface the raw platform response in syndication errors; soften 403 wording A target failure previously returned only a translated hint, so a 403 could not be diagnosed from the MCP surface (the tool returned the hint and an empty error record). Dev.to and Hashnode now include a one-line, length-capped slice of the platform's actual response body in the SyndicationError, so the raw Forem/Hashnode reason reaches the agent and the logs. The 403 wording is also softened: it no longer asserts the body/SVG as the cause (which misdirected a real diagnosis), and now reads "likely raw HTML/SVG in the post, or a key/permissions issue" with the raw response appended. Co-Authored-By: Claude Opus 4.8 --- src/DevToTarget.php | 16 ++++++++++++---- src/HashnodeTarget.php | 16 ++++++++++++---- tests/DevToTargetTest.php | 17 +++++++++++------ tests/HashnodeTargetTest.php | 12 ++++++++---- 4 files changed, 43 insertions(+), 18 deletions(-) diff --git a/src/DevToTarget.php b/src/DevToTarget.php index b00a941..8da83fc 100644 --- a/src/DevToTarget.php +++ b/src/DevToTarget.php @@ -60,11 +60,12 @@ public function push(array $post, ?string $externalId): array $body, ); - if ($resp['status'] === 403) { - throw new SyndicationError('Dev.to rejected the article body (HTTP 403), likely raw HTML or SVG in the post.'); - } if ($resp['status'] < 200 || $resp['status'] >= 300) { - throw new SyndicationError('Dev.to returned HTTP ' . $resp['status'] . '.'); + $hint = $resp['status'] === 403 + ? 'Dev.to rejected the request (HTTP 403), likely raw HTML/SVG in the post, or a key/permissions issue.' + : 'Dev.to returned HTTP ' . $resp['status'] . '.'; + $detail = $this->snippet($resp['body']); + throw new SyndicationError($detail === '' ? $hint : $hint . ' Response: ' . $detail); } $data = json_decode($resp['body'], true); $data = is_array($data) ? $data : []; @@ -75,6 +76,13 @@ public function push(array $post, ?string $externalId): array ]; } + /** A one-line, length-capped slice of the platform's response, for a diagnosable error. */ + private function snippet(string $body): string + { + $body = trim((string) preg_replace('/\s+/', ' ', $body)); + return mb_strlen($body) > 300 ? mb_substr($body, 0, 297) . '...' : $body; + } + /** * Dev.to tags must be lowercase alphanumeric and it accepts at most four. * diff --git a/src/HashnodeTarget.php b/src/HashnodeTarget.php index 2a00a70..25cbd37 100644 --- a/src/HashnodeTarget.php +++ b/src/HashnodeTarget.php @@ -90,11 +90,12 @@ public function push(array $post, ?string $externalId): array 'Accept' => 'application/json', ], $body); - if ($resp['status'] === 403) { - throw new SyndicationError('Hashnode rejected the request (HTTP 403), likely raw HTML or SVG in the post, or a token without Pro access.'); - } if ($resp['status'] < 200 || $resp['status'] >= 300) { - throw new SyndicationError('Hashnode returned HTTP ' . $resp['status'] . '.'); + $hint = $resp['status'] === 403 + ? 'Hashnode rejected the request (HTTP 403), likely raw HTML/SVG in the post, or a token/permissions issue (API writes need Pro).' + : 'Hashnode returned HTTP ' . $resp['status'] . '.'; + $detail = $this->snippet($resp['body']); + throw new SyndicationError($detail === '' ? $hint : $hint . ' Response: ' . $detail); } $data = json_decode($resp['body'], true); @@ -115,6 +116,13 @@ public function push(array $post, ?string $externalId): array ]; } + /** A one-line, length-capped slice of the platform's response, for a diagnosable error. */ + private function snippet(string $body): string + { + $body = trim((string) preg_replace('/\s+/', ' ', $body)); + return mb_strlen($body) > 300 ? mb_substr($body, 0, 297) . '...' : $body; + } + /** * Hashnode wants tags as {name, slug} objects: the slug is lowercase, hyphenated, * alphanumeric; the name keeps the author's wording. It recommends at most five. diff --git a/tests/DevToTargetTest.php b/tests/DevToTargetTest.php index 0538dae..0a91076 100644 --- a/tests/DevToTargetTest.php +++ b/tests/DevToTargetTest.php @@ -52,13 +52,18 @@ public function test_a_non_2xx_becomes_a_clear_error(): void (new DevToTarget(new FakeHttpClient(422, '{"error":"nope"}'), 'k'))->push($this->post(), null); } - public function test_a_403_is_translated_to_a_body_rejection_message(): void + public function test_a_403_message_is_open_about_the_cause_and_surfaces_the_response(): void { - // Forem answers 403 when it rejects the body (e.g. raw HTML/SVG); the message - // must point there, not at the API key. - $this->expectException(SyndicationError::class); - $this->expectExceptionMessage('raw HTML or SVG'); - (new DevToTarget(new FakeHttpClient(403, '{}'), 'k'))->push($this->post(), null); + // A 403 is not necessarily the body: it may be a key/permissions issue. The + // message must say so, and carry the raw platform response so it is diagnosable. + try { + (new DevToTarget(new FakeHttpClient(403, '{"error":"you are not allowed"}'), 'k'))->push($this->post(), null); + self::fail('expected a SyndicationError'); + } catch (SyndicationError $e) { + self::assertStringContainsString('HTTP 403', $e->getMessage()); + self::assertStringContainsString('key/permissions', $e->getMessage()); + self::assertStringContainsString('you are not allowed', $e->getMessage(), 'raw response is surfaced'); + } } public function test_unconfigured_reports_and_refuses(): void diff --git a/tests/HashnodeTargetTest.php b/tests/HashnodeTargetTest.php index 31ddf27..cfa2ad0 100644 --- a/tests/HashnodeTargetTest.php +++ b/tests/HashnodeTargetTest.php @@ -76,11 +76,15 @@ public function test_a_non_2xx_becomes_a_clear_error(): void $this->target(new FakeHttpClient(500, ''))->push($this->post(), null); } - public function test_a_403_is_translated_to_a_clear_message(): void + public function test_a_403_message_is_open_about_the_cause_and_surfaces_the_response(): void { - $this->expectException(SyndicationError::class); - $this->expectExceptionMessage('raw HTML or SVG'); - $this->target(new FakeHttpClient(403, ''))->push($this->post(), null); + try { + $this->target(new FakeHttpClient(403, '{"message":"forbidden detail"}'))->push($this->post(), null); + self::fail('expected a SyndicationError'); + } catch (SyndicationError $e) { + self::assertStringContainsString('HTTP 403', $e->getMessage()); + self::assertStringContainsString('forbidden detail', $e->getMessage(), 'raw response is surfaced'); + } } public function test_unconfigured_without_a_publication_reports_and_refuses(): void