fix(handoff): log HTTP error response body for observability (#501)

* fix(handoff): log HTTP error response body for observability When the Podcaster API returns a non-2xx HTTP error, the HTTPError handler now reads and includes the response body (truncated to 500 chars) in the error message. This enables diagnosing validation errors like duplicate job_id or stale article_path without needing to reproduce the failure. Context: run 27575695967 failed with HTTP 400 after a successful W25 episode run 1.5h earlier — likely a duplicate job_id or a missing content/weekly/2026/W25.md in the checkout. With this fix we would see the exact rejection reason from the Podcaster API. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix(handoff): sanitize error body and limit read to 1024 bytes - Read at most 1024 bytes from exc.read() to avoid unbounded memory use - Strip :: sequences (workflow-command injection prevention) - Replace newlines/carriage returns with spaces - Add test asserting body is included, truncated, and sanitized Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Juan Manuel Servera committed Jun 15, 2026 at 23:31 UTC 59ce813faa11ff361db0955f7c7192a8eb477ed0
2 files changed +46 -1
scripts/podcaster_handoff.py
+10 -1
@@ -445,7 +445,16 @@ def post_handoff(endpoint: str, api_key: str, payload: dict[str, Any], *, timeou
445 status_code = getattr(response, "status", response.getcode())
446 response_body = response.read().decode("utf-8")
447 except error.HTTPError as exc:
448 - raise PodcasterHandoffError(f"Podcaster handoff failed with HTTP {exc.code}.") from exc
448 + try:
449 + raw = exc.read(1024)
450 + error_body = raw.decode("utf-8", errors="replace")
451 + # Sanitize for GitHub Actions: strip workflow-command sequences and newlines
452 + error_body = error_body.replace("::", "").replace("\r", " ").replace("\n", " ")
453 + except Exception:
454 + error_body = "<unreadable>"
455 + raise PodcasterHandoffError(
456 + f"Podcaster handoff failed with HTTP {exc.code}. Response body: {error_body}"
457 + ) from exc
458 except error.URLError as exc:
459 raise PodcasterHandoffError(f"Podcaster handoff failed: {exc.reason}") from exc
460
tests/test_podcaster_handoff.py
+36
@@ -394,6 +394,42 @@ class PodcasterHandoffTests(unittest.TestCase):
394 },
395 )
396
397 + def test_error_body_included_and_sanitized_in_exception(self) -> None:
398 + """Error body is truncated to 1024 bytes and sanitized (no newlines, no ::)."""
399 + # Body with newlines, workflow-command injection, and > 1024 bytes
400 + dangerous_body = b"line1\n::warning::injected\r\n" + b"A" * 1100
401 + http_err = error.HTTPError(
402 + url="http://localhost:7071/api/generate",
403 + code=502,
404 + msg="Bad Gateway",
405 + hdrs={},
406 + fp=io.BytesIO(dangerous_body),
407 + )
408 + with mock.patch.object(podcaster_handoff.request, "urlopen", side_effect=http_err):
409 + with self.assertRaises(podcaster_handoff.PodcasterHandoffError) as ctx:
410 + podcaster_handoff.post_handoff(
411 + "http://localhost:7071/api/generate",
412 + "super-secret-value",
413 + {
414 + "week": "2026-W23",
415 + "article_url": "https://jmservera.github.io/SquadScope/weekly/2026/w23/",
416 + "article_path": "content/weekly/2026/W23.md",
417 + "publish_run_id": "123456789",
418 + "publish_mode": "normal",
419 + },
420 + )
421 + msg = str(ctx.exception)
422 + # Body IS included
423 + self.assertIn("Response body:", msg)
424 + self.assertIn("line1", msg)
425 + # Truncated: 1024 bytes read max, so not all 1100 'A's appear
426 + self.assertLessEqual(len(msg), 1200)
427 + # Sanitized: no newlines or :: sequences
428 + body_part = msg.split("Response body: ", 1)[1]
429 + self.assertNotIn("\n", body_part)
430 + self.assertNotIn("\r", body_part)
431 + self.assertNotIn("::", body_part)
432 +
433 def test_article_url_from_page_path_matches_hugo_weekly_permalink(self) -> None:
434 self.assertEqual(
435 podcaster_handoff.article_url_from_page_path(