From 0c6358ab98b4ecaccff4a162f135b67250c71faa Mon Sep 17 00:00:00 2001 From: Matt Van Horn <455140+mvanhorn@users.noreply.github.com> Date: Sun, 15 Mar 2026 22:13:28 -0700 Subject: [PATCH] fix(bluesky): surface real error instead of misleading "auth failed" When Cloudflare blocks requests to bsky.social or public.api.bsky.app with a 403, the error was swallowed by a generic except clause and reported as "Bluesky auth failed" - misleading users into thinking their credentials were wrong. Now _create_session() preserves the specific error in _session_error, and search_bluesky() surfaces it. Cloudflare 403s get a clear message about network-level blocks. Actual 401s say "Invalid credentials". Closes #69 Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/lib/bluesky.py | 28 ++++++++++++++++++++++------ tests/test_bluesky.py | 27 ++++++++++++++++++++++++--- 2 files changed, 46 insertions(+), 9 deletions(-) diff --git a/scripts/lib/bluesky.py b/scripts/lib/bluesky.py index 9bfcd43..3389366 100644 --- a/scripts/lib/bluesky.py +++ b/scripts/lib/bluesky.py @@ -23,6 +23,7 @@ DEPTH_CONFIG = { # Module-level token cache (valid for the lifetime of a single research run) _cached_token: Optional[str] = None +_session_error: Optional[str] = None def _log(msg: str): @@ -40,9 +41,9 @@ def _create_session(handle: str, app_password: str) -> Optional[str]: app_password: App password from bsky.app/settings/app-passwords Returns: - Access JWT string, or None on failure. + Access JWT string, or None on failure. Sets _session_error on failure. """ - global _cached_token + global _cached_token, _session_error if _cached_token: return _cached_token @@ -56,12 +57,24 @@ def _create_session(handle: str, app_password: str) -> Optional[str]: token = response.get("accessJwt") if token: _cached_token = token + _session_error = None _log("Session created successfully") return token _log("No accessJwt in session response") + _session_error = "No accessJwt in session response" + return None + except http.HTTPError as e: + if e.status_code == 403 and e.body and "cloudflare" in e.body.lower(): + _session_error = "Cloudflare blocked the request (403 Forbidden). This is a network-level block, not an auth issue. Try a different network or VPN." + elif e.status_code == 401: + _session_error = "Invalid credentials (401 Unauthorized). Check BSKY_HANDLE and BSKY_APP_PASSWORD." + else: + _session_error = f"Session request failed: {e}" + _log(f"Session creation failed: {_session_error}") return None except Exception as e: - _log(f"Session creation failed: {e}") + _session_error = f"Session request failed: {type(e).__name__}: {e}" + _log(f"Session creation failed: {_session_error}") return None @@ -122,7 +135,8 @@ def search_bluesky( # Authenticate token = _create_session(handle, app_password) if not token: - return {"posts": [], "error": "Bluesky auth failed"} + error_msg = _session_error or "Bluesky session creation failed (unknown error)" + return {"posts": [], "error": error_msg} count = DEPTH_CONFIG.get(depth, DEPTH_CONFIG["default"]) core_topic = _extract_core_subject(topic) @@ -145,10 +159,12 @@ def search_bluesky( ) except http.HTTPError as e: _log(f"Search failed: {e}") - return {"posts": [], "error": str(e)} + if e.status_code == 403 and e.body and "cloudflare" in e.body.lower(): + return {"posts": [], "error": "Bluesky search blocked by Cloudflare (403). This is a network-level block - try a different network or VPN."} + return {"posts": [], "error": f"Bluesky search failed: {e}"} except Exception as e: _log(f"Search failed: {e}") - return {"posts": [], "error": str(e)} + return {"posts": [], "error": f"Bluesky search failed: {type(e).__name__}: {e}"} posts = response.get("posts", []) _log(f"Found {len(posts)} posts") diff --git a/tests/test_bluesky.py b/tests/test_bluesky.py index 19b24e2..01174f0 100644 --- a/tests/test_bluesky.py +++ b/tests/test_bluesky.py @@ -126,6 +126,7 @@ class TestCreateSession(unittest.TestCase): mock_request.side_effect = Exception("connection refused") token = bluesky._create_session("user.bsky.social", "app-pw") self.assertIsNone(token) + self.assertIn("connection refused", bluesky._session_error) @patch("lib.bluesky.http.request") def test_returns_none_on_missing_jwt(self, mock_request): @@ -152,12 +153,32 @@ class TestSearchBlueskyAuth(unittest.TestCase): self.assertIn("not configured", result["error"]) @patch("lib.bluesky.http.request") - def test_auth_failure_returns_error(self, mock_request): - mock_request.side_effect = Exception("401 Unauthorized") + def test_auth_failure_returns_specific_error(self, mock_request): + mock_request.side_effect = Exception("connection refused") config = {"BSKY_HANDLE": "user.bsky.social", "BSKY_APP_PASSWORD": "pw"} result = bluesky.search_bluesky("test", "2026-01-01", "2026-03-09", config=config) self.assertEqual(result["posts"], []) - self.assertIn("auth failed", result["error"]) + self.assertIn("connection refused", result["error"]) + self.assertNotIn("auth failed", result["error"]) + + @patch("lib.bluesky.http.request") + def test_cloudflare_403_returns_network_error(self, mock_request): + from lib.http import HTTPError + mock_request.side_effect = HTTPError("HTTP 403: Forbidden", 403, "Cloudflare") + config = {"BSKY_HANDLE": "user.bsky.social", "BSKY_APP_PASSWORD": "pw"} + result = bluesky.search_bluesky("test", "2026-01-01", "2026-03-09", config=config) + self.assertEqual(result["posts"], []) + self.assertIn("Cloudflare", result["error"]) + self.assertIn("network", result["error"].lower()) + + @patch("lib.bluesky.http.request") + def test_401_returns_credentials_error(self, mock_request): + from lib.http import HTTPError + mock_request.side_effect = HTTPError("HTTP 401: Unauthorized", 401, "") + config = {"BSKY_HANDLE": "user.bsky.social", "BSKY_APP_PASSWORD": "pw"} + result = bluesky.search_bluesky("test", "2026-01-01", "2026-03-09", config=config) + self.assertEqual(result["posts"], []) + self.assertIn("Invalid credentials", result["error"]) @patch("lib.bluesky.http.request") def test_successful_search_passes_bearer(self, mock_request):