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
- _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)
251 lines
8.2 KiB
Python
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)
|