diff --git a/apps/api/tests/test_alert_chain_smoke_metric.py b/apps/api/tests/test_alert_chain_smoke_metric.py
index 3fcf9328..28864473 100644
--- a/apps/api/tests/test_alert_chain_smoke_metric.py
+++ b/apps/api/tests/test_alert_chain_smoke_metric.py
@@ -430,6 +430,53 @@ class AlertChainSmokeMetricTest(unittest.TestCase):
self.assertIn(["target_incident_id", "INC-20260505-25E744"], tags)
self.assertEqual(calls[0]["headers"]["X-AwoooP-Operator-Key"], "secret")
+ def test_source_link_canary_accepts_empty_2xx_for_downstream_readback(self):
+ def fake_post(url, payload, *, headers=None, timeout=None):
+ self.assertTrue(url.endswith("/api/v1/webhooks/sentry/error"))
+ self.assertEqual(payload["data"]["issue"]["title"], "AwoooPSourceLinkCanary")
+ return alert_chain_smoke_test.HttpGetResult(204, "")
+
+ original_post = alert_chain_smoke_test.http_post_json
+ try:
+ alert_chain_smoke_test.http_post_json = fake_post
+ result = alert_chain_smoke_test.send_source_link_canary(
+ "https://awoooi.example",
+ target_incident_id="INC-20260505-25E744",
+ operator_key="secret",
+ operator_id="gitea-e2e-health",
+ run_ref="run/123",
+ )
+ finally:
+ alert_chain_smoke_test.http_post_json = original_post
+
+ self.assertTrue(result.passed)
+ self.assertIn("source-correlation smoke must verify readback", result.message)
+
+ def test_source_link_canary_reports_http_error_before_json_parse(self):
+ def fake_post(url, payload, *, headers=None, timeout=None):
+ self.assertTrue(url.endswith("/api/v1/webhooks/sentry/error"))
+ return alert_chain_smoke_test.HttpGetResult(
+ 502,
+ "
bad gateway",
+ )
+
+ original_post = alert_chain_smoke_test.http_post_json
+ try:
+ alert_chain_smoke_test.http_post_json = fake_post
+ result = alert_chain_smoke_test.send_source_link_canary(
+ "https://awoooi.example",
+ target_incident_id="INC-20260505-25E744",
+ operator_key="secret",
+ operator_id="gitea-e2e-health",
+ run_ref="run/123",
+ )
+ finally:
+ alert_chain_smoke_test.http_post_json = original_post
+
+ self.assertFalse(result.passed)
+ self.assertIn("sentry HTTP 502", result.message)
+ self.assertIn("bad gateway", result.message)
+
if __name__ == "__main__":
unittest.main()
diff --git a/scripts/alert_chain_smoke_test.py b/scripts/alert_chain_smoke_test.py
index f0d71014..3cd6050d 100644
--- a/scripts/alert_chain_smoke_test.py
+++ b/scripts/alert_chain_smoke_test.py
@@ -137,6 +137,22 @@ def _http_error_message(error: Exception) -> str:
return str(error)
+def _response_body_preview(text: str, limit: int = 240) -> str:
+ cleaned = " ".join((text or "").split())
+ if not cleaned:
+ return ""
+ if len(cleaned) <= limit:
+ return cleaned
+ return f"{cleaned[:limit]}..."
+
+
+def _response_json(resp: HttpGetResult) -> dict[str, Any] | None:
+ text = (resp.text or "").strip()
+ if not text:
+ return None
+ return resp.json()
+
+
def _api_health_probe_summary(attempt: int) -> str:
return (
f"attempts={attempt}/{API_HEALTH_ATTEMPTS}, "
@@ -782,18 +798,43 @@ def send_source_provider_upstream_canary(
},
timeout=TIMEOUT,
)
- data = resp.json()
- except (URLError, TimeoutError, OSError, json.JSONDecodeError) as e:
+ except (URLError, TimeoutError, OSError) as e:
return CheckResult(
"Source Provider Upstream Canary",
False,
f"{provider} upstream canary failed: {_http_error_message(e)}",
)
if resp.status_code >= 400:
+ try:
+ data = _response_json(resp)
+ except json.JSONDecodeError:
+ data = None
+ detail = (
+ data.get("detail")
+ if isinstance(data, dict)
+ else _response_body_preview(resp.text)
+ )
return CheckResult(
"Source Provider Upstream Canary",
False,
- f"{provider} HTTP {resp.status_code}: {data.get('detail', resp.text) if isinstance(data, dict) else resp.text}",
+ f"{provider} HTTP {resp.status_code}: {detail}",
+ )
+ try:
+ data = _response_json(resp)
+ except json.JSONDecodeError:
+ return CheckResult(
+ "Source Provider Upstream Canary",
+ False,
+ (
+ f"{provider} upstream canary returned non-JSON "
+ f"HTTP {resp.status_code}: {_response_body_preview(resp.text)}"
+ ),
+ )
+ if data is None:
+ return CheckResult(
+ "Source Provider Upstream Canary",
+ False,
+ f"{provider} upstream canary returned empty HTTP {resp.status_code}",
)
validation_error = _validate_upstream_canary_response(provider, data)
if validation_error:
@@ -850,18 +891,46 @@ def send_source_link_canary(
},
timeout=TIMEOUT,
)
- data = resp.json()
- except (URLError, TimeoutError, OSError, json.JSONDecodeError) as e:
+ except (URLError, TimeoutError, OSError) as e:
return CheckResult(
"Source Link Canary",
False,
f"sentry source-link canary failed: {_http_error_message(e)}",
)
if resp.status_code >= 400:
+ try:
+ data = _response_json(resp)
+ except json.JSONDecodeError:
+ data = None
+ detail = (
+ data.get("detail")
+ if isinstance(data, dict)
+ else _response_body_preview(resp.text)
+ )
return CheckResult(
"Source Link Canary",
False,
- f"sentry HTTP {resp.status_code}: {data.get('detail', resp.text) if isinstance(data, dict) else resp.text}",
+ f"sentry HTTP {resp.status_code}: {detail}",
+ )
+ if not (resp.text or "").strip():
+ return CheckResult(
+ "Source Link Canary",
+ True,
+ (
+ "accepted sentry source-link canary post with empty "
+ f"HTTP {resp.status_code}; source-correlation smoke must verify readback"
+ ),
+ )
+ try:
+ data = resp.json()
+ except json.JSONDecodeError:
+ return CheckResult(
+ "Source Link Canary",
+ False,
+ (
+ "sentry source-link canary returned non-JSON "
+ f"HTTP {resp.status_code}: {_response_body_preview(resp.text)}"
+ ),
)
validation_error = _validate_upstream_canary_response("sentry", data)
if validation_error: