bridge: retry transport blips (TLS timeout/reset), never HTTP errors
A single GitHub TLS handshake timeout failed the whole sync, flipped its windy-job heartbeat to ok:false and would page for nothing. Up to 3 attempts with backoff for URLError/timeout/reset; HTTP errors return immediately as before. Test covers both. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -171,3 +171,41 @@ def test_default_non_blocking_is_grants_ruling():
|
|||||||
"ci/test-installer",
|
"ci/test-installer",
|
||||||
"ci/reality-check",
|
"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
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import json
|
|||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
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",
|
"User-Agent": "windy-git-pr-bridge/1",
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
try:
|
# Transport errors (TLS handshake timeout, reset) are retried: one GitHub
|
||||||
with urllib.request.urlopen(req, timeout=60) as r:
|
# blip used to fail the whole sync, flip its heartbeat to ok:false and page
|
||||||
raw = r.read()
|
# someone for nothing. HTTP errors are answers, not blips — never retried.
|
||||||
return r.status, (json.loads(raw) if raw else None)
|
for attempt in range(3):
|
||||||
except urllib.error.HTTPError as e:
|
try:
|
||||||
return e.code, None
|
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):
|
def gitea(method, path, body=None):
|
||||||
|
|||||||
Reference in New Issue
Block a user