Files
rune/tests/test_forge_client_resilience.py
T
Nick e5292cce7b
CI Pipeline / best-practices (push) Successful in 4s
CI Pipeline / documentation (push) Successful in 2s
CI Pipeline / canary (push) Successful in 31s
CI Pipeline / type-check (push) Failing after 28s
deploy / deploy (push) Skipped
CI Pipeline / lint (push) Successful in 37s
CI Pipeline / gitea-offline (push) Successful in 13s
CI Pipeline / unit-tests (push) Successful in 4m48s
CI Pipeline / required-checks (push) Failing after 1s
CI Pipeline / CI Pipeline / notify-event-bridge (push) Successful in 2s
feat(forge): add httpx retry/timeout wrapper for transient-failure resilience
- _request() helper in forge_client.py: retry on connect-phase transport
  errors and idempotent-method 5xx/429, exponential backoff, 30s timeout
  default preserved per attempt; create_issue (POST) and list-style GETs
  route through it per retry-where-noise semantics
- frozen contract tests/test_forge_client_resilience.py (10 tests,
  written red-first per the bug-fix contract)
- legacy FakeResponse fixtures updated with status_code=200 (wrapper
  reads status_code for the GET-5xx retry gate)
- httpx wrapper spec: docs/rebase-language-rubric.md W4 named improvement
- model_routing.py:99 probe deliberately EXCLUDED (failure is the
  signal, not noise — one-shot design is the contract)
2026-09-30 01:10:22 +01:00

251 lines
8.2 KiB
Python

"""httpx retry/timeout wrapper — frozen contract (rebase tranche W4 named improvement).
Contract shape (rubric `docs/rebase-language-rubric.md` W4 spec: "feature-add, not a
language change"):
- `_request(method, url, *, headers=None, json=None, timeout=30, retries=2,
backoff=0.5, sleep=None)` in `rune.forge_client` — retries where failure is
NOISE, never where failure is the SIGNAL:
- GET: retries on `httpx.TransportError` and 5xx status codes (transient
infrastructure failure; idempotent).
- POST: retries ONLY on connect-phase errors (`httpx.ConnectError`,
`httpx.ConnectTimeout`) — the request never reached the server, so a
retry cannot double-create. Read timeouts / write errors / 5xx on a POST
are NOT retried (the server may have processed it; a duplicate issue is
worse than a failure the auto-filer can catch).
- 4xx: never retried — the client error IS the information (e.g. the
dedup gate's 422s). Raises via `raise_for_status` on the first attempt.
- Attempts: `retries + 1` total. Backoff: `backoff * 2**n` before retry n+1
(0.5, 1.0, 2.0 …). Never sleeps before the first attempt.
- Timeout passes through to httpx per call.
- `sleep` is injectable (tests pass a recording fake); default `time.sleep`.
The adapter's five httpx call sites route through `_request`.
"""
import httpx
import pytest
from rune.forge_client import GiteaForgejoAdapter, _request
class FakeResponse:
def __init__(self, status_code=200, payload=None):
self.status_code = status_code
self._payload = payload if payload is not None else {}
def json(self):
return self._payload
def raise_for_status(self):
if self.status_code >= 400:
raise httpx.HTTPStatusError(
f"error {self.status_code}",
request=httpx.Request("GET", "https://x"),
response=httpx.Response(self.status_code),
)
class Recorder:
"""Records sleep durations; optionally raises on a configured attempt."""
def __init__(self):
self.calls = []
def test_get_retries_on_connect_error_then_succeeds():
attempts = []
def fake_get(url, **kwargs):
attempts.append(url)
if len(attempts) < 3:
raise httpx.ConnectError("conn refused")
return FakeResponse(200, {"ok": True})
sleeps = Recorder()
with __import__("unittest.mock", fromlist=["patch"]).patch(
"rune.forge_client.httpx.get", side_effect=fake_get
):
r = _request("GET", "https://x/api", sleep=sleeps.calls.append)
assert r.json() == {"ok": True}
assert len(attempts) == 3 # retries=2 → 3 attempts total
def test_get_retries_on_5xx_then_succeeds():
statuses = [502, 503, 200]
def fake_get(url, **kwargs):
code = statuses.pop(0)
return FakeResponse(code, {"n": 3 - len(statuses)})
with __import__("unittest.mock", fromlist=["patch"]).patch(
"rune.forge_client.httpx.get", side_effect=fake_get
):
r = _request("GET", "https://x/api", sleep=lambda s: None)
assert r.json() == {"n": 3}
def test_get_exhausts_retries_and_raises():
attempts = []
def fake_get(url, **kwargs):
attempts.append(url)
raise httpx.ConnectError("down")
with __import__("unittest.mock", fromlist=["patch"]).patch(
"rune.forge_client.httpx.get", side_effect=fake_get
), pytest.raises(httpx.ConnectError):
_request("GET", "https://x/api", sleep=lambda s: None)
assert len(attempts) == 3 # 1 + 2 retries, then the last error propagates
def test_get_does_not_retry_4xx():
attempts = []
def fake_get(url, **kwargs):
attempts.append(url)
return FakeResponse(422)
with __import__("unittest.mock", fromlist=["patch"]).patch(
"rune.forge_client.httpx.get", side_effect=fake_get
), pytest.raises(httpx.HTTPStatusError):
_request("GET", "https://x/api", sleep=lambda s: None)
assert len(attempts) == 1 # the 422 IS the information — one shot
def test_post_retries_on_connect_error_only():
# Connect-phase failure: the request never left the machine — safe to
# retry even for a POST (no double-create possible).
attempts = []
def fake_post(url, **kwargs):
attempts.append(url)
if len(attempts) < 2:
raise httpx.ConnectTimeout("never connected")
return FakeResponse(201, {"number": 7})
with __import__("unittest.mock", fromlist=["patch"]).patch(
"rune.forge_client.httpx.post", side_effect=fake_post
):
r = _request("POST", "https://x/api", json={"a": 1}, sleep=lambda s: None)
assert r.json() == {"number": 7}
assert len(attempts) == 2
def test_post_does_not_retry_read_timeout():
# A read timeout means the server MAY have processed the POST — a retry
# could double-create an issue. The failure must propagate immediately;
# the auto-filer exists to catch genuine failures.
attempts = []
def fake_post(url, **kwargs):
attempts.append(url)
raise httpx.ReadTimeout("server went quiet")
with __import__("unittest.mock", fromlist=["patch"]).patch(
"rune.forge_client.httpx.post", side_effect=fake_post
), pytest.raises(httpx.ReadTimeout):
_request("POST", "https://x/api", json={"a": 1}, sleep=lambda s: None)
assert len(attempts) == 1
def test_post_does_not_retry_5xx():
# A 502 after send is ambiguous — the server may have processed it.
attempts = []
def fake_post(url, **kwargs):
attempts.append(url)
return FakeResponse(502)
with __import__("unittest.mock", fromlist=["patch"]).patch(
"rune.forge_client.httpx.post", side_effect=fake_post
), pytest.raises(httpx.HTTPStatusError):
_request("POST", "https://x/api", json={"a": 1}, sleep=lambda s: None)
assert len(attempts) == 1
def test_backoff_is_exponential_and_never_before_first_attempt():
attempts = []
def fake_get(url, **kwargs):
attempts.append(url)
if len(attempts) < 3:
raise httpx.ConnectError("down")
return FakeResponse(200)
sleeps = []
with __import__("unittest.mock", fromlist=["patch"]).patch(
"rune.forge_client.httpx.get", side_effect=fake_get
):
_request("GET", "https://x/api", retries=2, backoff=0.5, sleep=sleeps.append)
assert len(attempts) == 3
assert len(sleeps) == 2 # only between attempts, never before the first
assert sleeps == [0.5, 1.0] # backoff * 2**n
def test_timeout_passes_through_per_call():
captured = {}
def fake_get(url, **kwargs):
captured["timeout"] = kwargs.get("timeout")
return FakeResponse(200)
with __import__("unittest.mock", fromlist=["patch"]).patch(
"rune.forge_client.httpx.get", side_effect=fake_get
):
_request("GET", "https://x/api", timeout=17, sleep=lambda s: None)
assert captured["timeout"] == 17
def test_adapter_routes_through_wrapper():
# All five adapter call sites must go through the resilient path —
# a bare httpx call bypassing the wrapper would silently regress the
# reliability upgrade.
adapter = GiteaForgejoAdapter("https://forge.example.com", "tok", owner="o", repo="r")
seen = []
class FakeResponse:
def __init__(self, payload):
self._payload = payload
def json(self):
return self._payload
def raise_for_status(self):
pass
def fake_request(method, url, **kwargs):
seen.append((method, url))
if len(seen) == 1:
return FakeResponse({"number": 1})
if len(seen) == 2:
return FakeResponse({"ok": True})
if len(seen) == 3:
return FakeResponse({"state": "open"})
if len(seen) == 4:
return FakeResponse([])
return FakeResponse([])
with __import__("unittest.mock", fromlist=["patch"]).patch(
"rune.forge_client._request", side_effect=fake_request
):
adapter.create_issue("t", "b")
adapter.add_comment(1, "c")
adapter.get_issue_state(1)
adapter.list_open_issues()
adapter.search_issues("q")
methods = [m for m, _ in seen]
assert methods == ["POST", "POST", "GET", "GET", "GET"]
assert all(u.startswith("https://forge.example.com/api/v1/repos/o/r/") for _, u in seen)