diff --git a/api/tests/test_pr_status_bridge.py b/api/tests/test_pr_status_bridge.py index eaeb8e9..9a089ae 100644 --- a/api/tests/test_pr_status_bridge.py +++ b/api/tests/test_pr_status_bridge.py @@ -171,3 +171,41 @@ def test_default_non_blocking_is_grants_ruling(): "ci/test-installer", "ci/reality-check", } + + +def test_transport_blips_are_retried_but_http_errors_are_not(monkeypatch): + import urllib.error + + calls = {"n": 0} + + class _R: + status = 200 + + def read(self): + return b"{}" + + def __enter__(self): + return self + + def __exit__(self, *a): + return False + + def flaky(req, timeout): + calls["n"] += 1 + if calls["n"] < 3: + raise urllib.error.URLError("_ssl.c:983: The handshake operation timed out") + return _R() + + monkeypatch.setattr(bridge.urllib.request, "urlopen", flaky) + monkeypatch.setattr(bridge.time, "sleep", lambda s: None) + assert bridge._call("http://x", "t", "GET", "/p") == (200, {}) + assert calls["n"] == 3 + + def forbidden(req, timeout): + calls["n"] += 1 + raise urllib.error.HTTPError("http://x/p", 403, "no", {}, None) + + calls["n"] = 0 + monkeypatch.setattr(bridge.urllib.request, "urlopen", forbidden) + assert bridge._call("http://x", "t", "GET", "/p") == (403, None) + assert calls["n"] == 1 diff --git a/scripts/pr_status_bridge.py b/scripts/pr_status_bridge.py index 70c517c..a6c8cc2 100755 --- a/scripts/pr_status_bridge.py +++ b/scripts/pr_status_bridge.py @@ -32,6 +32,7 @@ import json import os import re import sys +import time import urllib.error import urllib.request @@ -98,12 +99,21 @@ def _call(base: str, token_header: str, method: str, path: str, body=None): "User-Agent": "windy-git-pr-bridge/1", }, ) - try: - with urllib.request.urlopen(req, timeout=60) as r: - raw = r.read() - return r.status, (json.loads(raw) if raw else None) - except urllib.error.HTTPError as e: - return e.code, None + # Transport errors (TLS handshake timeout, reset) are retried: one GitHub + # blip used to fail the whole sync, flip its heartbeat to ok:false and page + # someone for nothing. HTTP errors are answers, not blips — never retried. + for attempt in range(3): + try: + with urllib.request.urlopen(req, timeout=60) as r: + raw = r.read() + return r.status, (json.loads(raw) if raw else None) + except urllib.error.HTTPError as e: + return e.code, None + except (urllib.error.URLError, TimeoutError, ConnectionError): + if attempt == 2: + raise + time.sleep(2 * (attempt + 1)) + raise AssertionError("unreachable") def gitea(method, path, body=None):