From 127992a2358abd41413139dba0ac0ccd544a484c Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 08:32:23 +0000 Subject: [PATCH 1/2] Add retry and rate-limit handling to GitHubClient --- src/opencode_github/__init__.py | 9 +- src/opencode_github/config.py | 19 ++ src/opencode_github/github_client.py | 164 +++++++++++++- tests/test_config.py | 32 +++ tests/test_github_client.py | 306 ++++++++++++++++++++++++++- 5 files changed, 521 insertions(+), 9 deletions(-) diff --git a/src/opencode_github/__init__.py b/src/opencode_github/__init__.py index 2443e4b..5312961 100644 --- a/src/opencode_github/__init__.py +++ b/src/opencode_github/__init__.py @@ -15,7 +15,13 @@ LearnerProfile, LearningChallenge, ) -from opencode_github.github_client import GitHubAPIError, GitHubClient, IssueComment, PullRequest +from opencode_github.github_client import ( + GitHubAPIError, + GitHubClient, + IssueComment, + PullRequest, + RateLimitError, +) from opencode_github.webhook_handler import EventType, WebhookEvent __all__ = [ @@ -32,6 +38,7 @@ "LearningChallenge", "ParsedCommand", "PullRequest", + "RateLimitError", "WebhookEvent", "extract_commands", "is_command_comment", diff --git a/src/opencode_github/config.py b/src/opencode_github/config.py index 705b120..b6af324 100644 --- a/src/opencode_github/config.py +++ b/src/opencode_github/config.py @@ -16,6 +16,8 @@ class Config: github_api_url: str = "https://api.github.com" allowed_commands: list[str] = field(default_factory=lambda: ["/oc", "/opencode"]) request_timeout: int = 30 + max_retries: int = 3 + backoff_factor: float = 0.5 @classmethod def from_env(cls, environ: dict[str, str] | None = None) -> Config: @@ -53,6 +55,21 @@ def from_env(cls, environ: dict[str, str] | None = None) -> Config: except ValueError: request_timeout = 30 + retries_raw = env.get("OPENCODE_MAX_RETRIES", "3").strip() + try: + max_retries = int(retries_raw) + except ValueError: + max_retries = 3 + max_retries = max(0, max_retries) + + backoff_raw = env.get("OPENCODE_BACKOFF_FACTOR", "0.5").strip() + try: + backoff_factor = float(backoff_raw) + except ValueError: + backoff_factor = 0.5 + if backoff_factor < 0: + backoff_factor = 0.5 + return cls( github_token=github_token, anthropic_api_key=anthropic_api_key, @@ -60,4 +77,6 @@ def from_env(cls, environ: dict[str, str] | None = None) -> Config: github_api_url=github_api_url, allowed_commands=allowed_commands, request_timeout=request_timeout, + max_retries=max_retries, + backoff_factor=backoff_factor, ) diff --git a/src/opencode_github/github_client.py b/src/opencode_github/github_client.py index dfb357e..32d11e1 100644 --- a/src/opencode_github/github_client.py +++ b/src/opencode_github/github_client.py @@ -2,11 +2,20 @@ from __future__ import annotations +import asyncio +import random +import time +from collections.abc import Awaitable, Callable from dataclasses import dataclass from typing import Any import httpx +from opencode_github.config import Config + +# Server-side status codes worth retrying as transient failures. +RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({500, 502, 503, 504}) + @dataclass(frozen=True) class PullRequest: @@ -38,6 +47,33 @@ def __init__(self, status_code: int, detail: str) -> None: super().__init__(f"GitHub API error {status_code}: {detail}") +class RateLimitError(GitHubAPIError): + """Raised when the GitHub rate limit is exhausted after all retries. + + Parameters + ---------- + status_code: + The HTTP status code returned (``429`` or ``403``). + detail: + Response body text. + retry_after: + Parsed ``Retry-After`` header value in seconds, when present. + reset_at: + Parsed ``X-RateLimit-Reset`` header value (epoch seconds), when present. + """ + + def __init__( + self, + status_code: int, + detail: str, + retry_after: float | None = None, + reset_at: float | None = None, + ) -> None: + self.retry_after = retry_after + self.reset_at = reset_at + super().__init__(status_code, detail) + + class GitHubClient: """Async GitHub REST API client. @@ -56,9 +92,17 @@ def __init__( token: str, base_url: str = "https://api.github.com", timeout: int = 30, + max_retries: int = 3, + backoff_factor: float = 0.5, + max_backoff: float = 60.0, + sleep: Callable[[float], Awaitable[None]] | None = None, ) -> None: self._token = token self._base_url = base_url.rstrip("/") + self._max_retries = max(0, max_retries) + self._backoff_factor = backoff_factor + self._max_backoff = max_backoff + self._sleep = sleep or asyncio.sleep self._client = httpx.AsyncClient( base_url=self._base_url, headers={ @@ -69,6 +113,22 @@ def __init__( timeout=timeout, ) + @classmethod + def from_config( + cls, + config: Config, + sleep: Callable[[float], Awaitable[None]] | None = None, + ) -> GitHubClient: + """Build a client from a :class:`~opencode_github.config.Config`.""" + return cls( + token=config.github_token, + base_url=config.github_api_url, + timeout=config.request_timeout, + max_retries=config.max_retries, + backoff_factor=config.backoff_factor, + sleep=sleep, + ) + async def close(self) -> None: await self._client.aclose() @@ -78,13 +138,105 @@ async def __aenter__(self) -> GitHubClient: async def __aexit__(self, *exc: object) -> None: await self.close() + @staticmethod + def _is_rate_limited(resp: httpx.Response) -> bool: + """Return ``True`` when a response indicates a rate-limit condition. + + GitHub signals primary rate limits with ``403`` and + ``X-RateLimit-Remaining: 0`` and secondary rate limits with ``429`` or + a ``Retry-After`` header. + """ + if resp.status_code == 429: + return True + if resp.status_code == 403: + if resp.headers.get("x-ratelimit-remaining") == "0": + return True + if resp.headers.get("retry-after"): + return True + return False + + def _backoff_delay(self, attempt: int) -> float: + """Exponential backoff with full jitter for retry *attempt* (0-based).""" + base = self._backoff_factor * (2**attempt) + jitter = random.uniform(0, self._backoff_factor) + return min(base + jitter, self._max_backoff) + + def _retry_delay(self, resp: httpx.Response, attempt: int) -> float: + """Compute the delay before retrying a rate-limited *resp*. + + Honors ``Retry-After`` and ``X-RateLimit-Reset`` headers, falling back + to exponential backoff. + """ + retry_after = resp.headers.get("retry-after") + if retry_after: + try: + return min(max(0.0, float(retry_after)), self._max_backoff) + except ValueError: + pass + + if resp.headers.get("x-ratelimit-remaining") == "0": + reset = resp.headers.get("x-ratelimit-reset") + if reset: + try: + delay = float(reset) - time.time() + except ValueError: + delay = 0.0 + if delay > 0: + return min(delay, self._max_backoff) + + return self._backoff_delay(attempt) + + @staticmethod + def _rate_limit_error(resp: httpx.Response) -> RateLimitError: + retry_after: float | None = None + raw_retry_after = resp.headers.get("retry-after") + if raw_retry_after: + try: + retry_after = float(raw_retry_after) + except ValueError: + retry_after = None + + reset_at: float | None = None + raw_reset = resp.headers.get("x-ratelimit-reset") + if raw_reset: + try: + reset_at = float(raw_reset) + except ValueError: + reset_at = None + + return RateLimitError(resp.status_code, resp.text, retry_after, reset_at) + async def _request(self, method: str, path: str, **kwargs: Any) -> Any: - resp = await self._client.request(method, path, **kwargs) - if resp.status_code >= 400: - raise GitHubAPIError(resp.status_code, resp.text) - if resp.status_code == 204: - return None - return resp.json() + attempt = 0 + while True: + try: + resp = await self._client.request(method, path, **kwargs) + except httpx.TransportError: + if attempt >= self._max_retries: + raise + await self._sleep(self._backoff_delay(attempt)) + attempt += 1 + continue + + if self._is_rate_limited(resp): + if attempt < self._max_retries: + await self._sleep(self._retry_delay(resp, attempt)) + attempt += 1 + continue + raise self._rate_limit_error(resp) + + if resp.status_code in RETRYABLE_STATUS_CODES: + if attempt < self._max_retries: + await self._sleep(self._backoff_delay(attempt)) + attempt += 1 + continue + raise GitHubAPIError(resp.status_code, resp.text) + + if resp.status_code >= 400: + raise GitHubAPIError(resp.status_code, resp.text) + if resp.status_code == 204: + return None + return resp.json() async def get_pull_request(self, owner: str, repo: str, number: int) -> PullRequest: data = await self._request("GET", f"/repos/{owner}/{repo}/pulls/{number}") diff --git a/tests/test_config.py b/tests/test_config.py index 183deff..585ce4d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -44,6 +44,38 @@ def test_invalid_timeout_falls_back(self, minimal_env: dict[str, str]) -> None: cfg = Config.from_env(minimal_env) assert cfg.request_timeout == 30 + def test_default_retry_settings(self, minimal_env: dict[str, str]) -> None: + cfg = Config.from_env(minimal_env) + assert cfg.max_retries == 3 + assert cfg.backoff_factor == 0.5 + + def test_custom_retry_settings(self, minimal_env: dict[str, str]) -> None: + minimal_env["OPENCODE_MAX_RETRIES"] = "6" + minimal_env["OPENCODE_BACKOFF_FACTOR"] = "2.5" + cfg = Config.from_env(minimal_env) + assert cfg.max_retries == 6 + assert cfg.backoff_factor == 2.5 + + def test_invalid_max_retries_falls_back(self, minimal_env: dict[str, str]) -> None: + minimal_env["OPENCODE_MAX_RETRIES"] = "lots" + cfg = Config.from_env(minimal_env) + assert cfg.max_retries == 3 + + def test_negative_max_retries_clamped_to_zero(self, minimal_env: dict[str, str]) -> None: + minimal_env["OPENCODE_MAX_RETRIES"] = "-2" + cfg = Config.from_env(minimal_env) + assert cfg.max_retries == 0 + + def test_invalid_backoff_factor_falls_back(self, minimal_env: dict[str, str]) -> None: + minimal_env["OPENCODE_BACKOFF_FACTOR"] = "fast" + cfg = Config.from_env(minimal_env) + assert cfg.backoff_factor == 0.5 + + def test_negative_backoff_factor_falls_back(self, minimal_env: dict[str, str]) -> None: + minimal_env["OPENCODE_BACKOFF_FACTOR"] = "-1.0" + cfg = Config.from_env(minimal_env) + assert cfg.backoff_factor == 0.5 + def test_whitespace_stripped(self, minimal_env: dict[str, str]) -> None: minimal_env["GITHUB_TOKEN"] = " token_with_spaces " minimal_env["ANTHROPIC_API_KEY"] = "\tkey_with_tabs\t" diff --git a/tests/test_github_client.py b/tests/test_github_client.py index 9c0adef..61db0e8 100644 --- a/tests/test_github_client.py +++ b/tests/test_github_client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import time from collections.abc import Iterator import httpx @@ -13,11 +14,22 @@ GitHubClient, IssueComment, PullRequest, + RateLimitError, ) BASE = "https://api.github.com" +class RecordingSleep: + """An async sleep stub that records requested delays without waiting.""" + + def __init__(self) -> None: + self.calls: list[float] = [] + + async def __call__(self, delay: float) -> None: + self.calls.append(delay) + + @pytest.fixture() def mock_router() -> Iterator[respx.MockRouter]: with respx.mock(base_url=BASE, assert_all_called=False) as router: @@ -25,8 +37,13 @@ def mock_router() -> Iterator[respx.MockRouter]: @pytest.fixture() -def client() -> GitHubClient: - return GitHubClient(token="test-token", base_url=BASE, timeout=5) +def sleep_stub() -> RecordingSleep: + return RecordingSleep() + + +@pytest.fixture() +def client(sleep_stub: RecordingSleep) -> GitHubClient: + return GitHubClient(token="test-token", base_url=BASE, timeout=5, sleep=sleep_stub) class TestGetPullRequest: @@ -155,3 +172,288 @@ class TestContextManager: async def test_async_with(self) -> None: async with GitHubClient(token="tok") as c: assert c._token == "tok" + + +class TestRetryAndRateLimit: + async def test_success_first_try_no_sleep( + self, + mock_router: respx.MockRouter, + client: GitHubClient, + sleep_stub: RecordingSleep, + ) -> None: + mock_router.get("/repos/owner/repo").mock( + return_value=httpx.Response(200, json={"full_name": "owner/repo"}) + ) + data = await client.get_repo("owner", "repo") + assert data["full_name"] == "owner/repo" + assert sleep_stub.calls == [] + + async def test_retries_5xx_then_succeeds( + self, + mock_router: respx.MockRouter, + client: GitHubClient, + sleep_stub: RecordingSleep, + ) -> None: + route = mock_router.get("/repos/owner/repo") + route.side_effect = [ + httpx.Response(503, text="unavailable"), + httpx.Response(502, text="bad gateway"), + httpx.Response(200, json={"full_name": "owner/repo"}), + ] + data = await client.get_repo("owner", "repo") + assert data["full_name"] == "owner/repo" + assert len(sleep_stub.calls) == 2 + assert all(delay > 0 for delay in sleep_stub.calls) + + async def test_5xx_exhausts_retries_and_raises( + self, + mock_router: respx.MockRouter, + client: GitHubClient, + sleep_stub: RecordingSleep, + ) -> None: + mock_router.get("/repos/owner/repo").mock( + return_value=httpx.Response(500, text="boom") + ) + with pytest.raises(GitHubAPIError) as exc_info: + await client.get_repo("owner", "repo") + assert exc_info.value.status_code == 500 + # default max_retries=3 → 4 attempts → 3 sleeps + assert len(sleep_stub.calls) == 3 + + async def test_4xx_not_retried( + self, + mock_router: respx.MockRouter, + client: GitHubClient, + sleep_stub: RecordingSleep, + ) -> None: + mock_router.get("/repos/owner/repo/pulls/1").mock( + return_value=httpx.Response(404, json={"message": "Not Found"}) + ) + with pytest.raises(GitHubAPIError) as exc_info: + await client.get_pull_request("owner", "repo", 1) + assert exc_info.value.status_code == 404 + assert sleep_stub.calls == [] + + async def test_429_retries_then_succeeds( + self, + mock_router: respx.MockRouter, + client: GitHubClient, + sleep_stub: RecordingSleep, + ) -> None: + route = mock_router.get("/repos/owner/repo") + route.side_effect = [ + httpx.Response(429, text="slow down"), + httpx.Response(200, json={"full_name": "owner/repo"}), + ] + data = await client.get_repo("owner", "repo") + assert data["full_name"] == "owner/repo" + assert len(sleep_stub.calls) == 1 + + async def test_429_exhausted_raises_rate_limit_error( + self, + mock_router: respx.MockRouter, + client: GitHubClient, + ) -> None: + mock_router.get("/repos/owner/repo").mock( + return_value=httpx.Response( + 429, + text="rate limited", + headers={"retry-after": "7", "x-ratelimit-reset": "1700000000"}, + ) + ) + with pytest.raises(RateLimitError) as exc_info: + await client.get_repo("owner", "repo") + assert exc_info.value.status_code == 429 + assert exc_info.value.retry_after == 7.0 + assert exc_info.value.reset_at == 1700000000.0 + + async def test_retry_after_header_honored( + self, + mock_router: respx.MockRouter, + client: GitHubClient, + sleep_stub: RecordingSleep, + ) -> None: + route = mock_router.get("/repos/owner/repo") + route.side_effect = [ + httpx.Response(429, headers={"retry-after": "4"}, text="wait"), + httpx.Response(200, json={"full_name": "owner/repo"}), + ] + await client.get_repo("owner", "repo") + assert sleep_stub.calls == [4.0] + + async def test_403_with_remaining_zero_is_rate_limited( + self, + mock_router: respx.MockRouter, + client: GitHubClient, + sleep_stub: RecordingSleep, + ) -> None: + route = mock_router.get("/repos/owner/repo") + route.side_effect = [ + httpx.Response(403, headers={"x-ratelimit-remaining": "0"}, text="limit"), + httpx.Response(200, json={"full_name": "owner/repo"}), + ] + data = await client.get_repo("owner", "repo") + assert data["full_name"] == "owner/repo" + assert len(sleep_stub.calls) == 1 + + async def test_403_reset_header_drives_delay( + self, + mock_router: respx.MockRouter, + client: GitHubClient, + sleep_stub: RecordingSleep, + ) -> None: + reset_at = time.time() + 5 + route = mock_router.get("/repos/owner/repo") + route.side_effect = [ + httpx.Response( + 403, + headers={ + "x-ratelimit-remaining": "0", + "x-ratelimit-reset": str(int(reset_at)), + }, + text="limit", + ), + httpx.Response(200, json={"full_name": "owner/repo"}), + ] + await client.get_repo("owner", "repo") + assert len(sleep_stub.calls) == 1 + assert 0 < sleep_stub.calls[0] <= 6 + + async def test_403_with_retry_after_only_is_rate_limited( + self, + mock_router: respx.MockRouter, + client: GitHubClient, + sleep_stub: RecordingSleep, + ) -> None: + route = mock_router.get("/repos/owner/repo") + route.side_effect = [ + httpx.Response(403, headers={"retry-after": "3"}, text="secondary"), + httpx.Response(200, json={"full_name": "owner/repo"}), + ] + await client.get_repo("owner", "repo") + assert sleep_stub.calls == [3.0] + + async def test_rate_limit_error_with_invalid_headers_has_none_fields( + self, + mock_router: respx.MockRouter, + client: GitHubClient, + ) -> None: + mock_router.get("/repos/owner/repo").mock( + return_value=httpx.Response( + 429, + text="rate limited", + headers={"retry-after": "soon", "x-ratelimit-reset": "later"}, + ) + ) + with pytest.raises(RateLimitError) as exc_info: + await client.get_repo("owner", "repo") + assert exc_info.value.retry_after is None + assert exc_info.value.reset_at is None + + async def test_204_returns_none( + self, + mock_router: respx.MockRouter, + client: GitHubClient, + ) -> None: + mock_router.post("/repos/owner/repo/issues/comments/9/reactions").mock( + return_value=httpx.Response(204) + ) + result = await client.add_reaction("owner", "repo", 9) + assert result is None + + async def test_403_without_rate_limit_signal_not_retried( + self, + mock_router: respx.MockRouter, + client: GitHubClient, + sleep_stub: RecordingSleep, + ) -> None: + mock_router.get("/repos/owner/repo").mock( + return_value=httpx.Response(403, text="forbidden") + ) + with pytest.raises(GitHubAPIError) as exc_info: + await client.get_repo("owner", "repo") + assert exc_info.value.status_code == 403 + assert not isinstance(exc_info.value, RateLimitError) + assert sleep_stub.calls == [] + + async def test_transport_error_retried_then_succeeds( + self, + mock_router: respx.MockRouter, + client: GitHubClient, + sleep_stub: RecordingSleep, + ) -> None: + route = mock_router.get("/repos/owner/repo") + route.side_effect = [ + httpx.ConnectError("boom"), + httpx.Response(200, json={"full_name": "owner/repo"}), + ] + data = await client.get_repo("owner", "repo") + assert data["full_name"] == "owner/repo" + assert len(sleep_stub.calls) == 1 + + async def test_transport_error_exhausted_reraises( + self, + mock_router: respx.MockRouter, + sleep_stub: RecordingSleep, + ) -> None: + client = GitHubClient( + token="t", base_url=BASE, max_retries=1, sleep=sleep_stub + ) + mock_router.get("/repos/owner/repo").mock(side_effect=httpx.ConnectError("down")) + with pytest.raises(httpx.ConnectError): + await client.get_repo("owner", "repo") + assert len(sleep_stub.calls) == 1 + + async def test_max_retries_zero_disables_retry( + self, + mock_router: respx.MockRouter, + sleep_stub: RecordingSleep, + ) -> None: + client = GitHubClient(token="t", base_url=BASE, max_retries=0, sleep=sleep_stub) + mock_router.get("/repos/owner/repo").mock( + return_value=httpx.Response(500, text="boom") + ) + with pytest.raises(GitHubAPIError): + await client.get_repo("owner", "repo") + assert sleep_stub.calls == [] + + async def test_backoff_factor_caps_at_max_backoff(self) -> None: + client = GitHubClient( + token="t", backoff_factor=1000.0, max_backoff=2.0, sleep=None + ) + assert client._backoff_delay(5) == 2.0 + + async def test_invalid_retry_after_falls_back_to_backoff( + self, + mock_router: respx.MockRouter, + client: GitHubClient, + sleep_stub: RecordingSleep, + ) -> None: + route = mock_router.get("/repos/owner/repo") + route.side_effect = [ + httpx.Response(429, headers={"retry-after": "soon"}, text="wait"), + httpx.Response(200, json={"full_name": "owner/repo"}), + ] + await client.get_repo("owner", "repo") + assert len(sleep_stub.calls) == 1 + assert sleep_stub.calls[0] > 0 + + +class TestFromConfig: + def test_threads_config_values(self) -> None: + from opencode_github.config import Config + + cfg = Config.from_env( + { + "GITHUB_TOKEN": "tok", + "ANTHROPIC_API_KEY": "key", + "GITHUB_API_URL": "https://ghe.example.com/api/v3", + "OPENCODE_TIMEOUT": "12", + "OPENCODE_MAX_RETRIES": "5", + "OPENCODE_BACKOFF_FACTOR": "1.5", + } + ) + client = GitHubClient.from_config(cfg) + assert client._base_url == "https://ghe.example.com/api/v3" + assert client._max_retries == 5 + assert client._backoff_factor == 1.5 From f0eb1188c0395f4066edb955398afe2a9c52399a Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 9 Jun 2026 08:37:34 +0000 Subject: [PATCH 2/2] Make retries idempotency-aware to avoid duplicating POSTs --- src/opencode_github/github_client.py | 21 +++++++---- tests/test_github_client.py | 52 ++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 6 deletions(-) diff --git a/src/opencode_github/github_client.py b/src/opencode_github/github_client.py index 32d11e1..a6ebc69 100644 --- a/src/opencode_github/github_client.py +++ b/src/opencode_github/github_client.py @@ -16,6 +16,11 @@ # Server-side status codes worth retrying as transient failures. RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({500, 502, 503, 504}) +# HTTP methods that are safe to retry on transient (5xx / network) failures. +# Non-idempotent methods (e.g. POST) are excluded so a partially-applied request +# is not duplicated. +IDEMPOTENT_METHODS: frozenset[str] = frozenset({"GET", "HEAD", "OPTIONS", "PUT", "DELETE"}) + @dataclass(frozen=True) class PullRequest: @@ -207,16 +212,20 @@ def _rate_limit_error(resp: httpx.Response) -> RateLimitError: return RateLimitError(resp.status_code, resp.text, retry_after, reset_at) async def _request(self, method: str, path: str, **kwargs: Any) -> Any: + # Transient failures (network errors, 5xx) are only retried for + # idempotent methods; a rate-limited response means the request was + # rejected before taking effect, so it is safe to retry for any method. + retry_transient = method.upper() in IDEMPOTENT_METHODS attempt = 0 while True: try: resp = await self._client.request(method, path, **kwargs) except httpx.TransportError: - if attempt >= self._max_retries: - raise - await self._sleep(self._backoff_delay(attempt)) - attempt += 1 - continue + if retry_transient and attempt < self._max_retries: + await self._sleep(self._backoff_delay(attempt)) + attempt += 1 + continue + raise if self._is_rate_limited(resp): if attempt < self._max_retries: @@ -226,7 +235,7 @@ async def _request(self, method: str, path: str, **kwargs: Any) -> Any: raise self._rate_limit_error(resp) if resp.status_code in RETRYABLE_STATUS_CODES: - if attempt < self._max_retries: + if retry_transient and attempt < self._max_retries: await self._sleep(self._backoff_delay(attempt)) attempt += 1 continue diff --git a/tests/test_github_client.py b/tests/test_github_client.py index 61db0e8..ba42ed1 100644 --- a/tests/test_github_client.py +++ b/tests/test_github_client.py @@ -439,6 +439,58 @@ async def test_invalid_retry_after_falls_back_to_backoff( assert sleep_stub.calls[0] > 0 +class TestIdempotencyAwareRetry: + async def test_post_5xx_not_retried( + self, + mock_router: respx.MockRouter, + client: GitHubClient, + sleep_stub: RecordingSleep, + ) -> None: + mock_router.post("/repos/owner/repo/issues/1/comments").mock( + return_value=httpx.Response(503, text="unavailable") + ) + with pytest.raises(GitHubAPIError) as exc_info: + await client.create_issue_comment("owner", "repo", 1, "hi") + assert exc_info.value.status_code == 503 + assert sleep_stub.calls == [] + + async def test_post_transport_error_not_retried( + self, + mock_router: respx.MockRouter, + client: GitHubClient, + sleep_stub: RecordingSleep, + ) -> None: + mock_router.post("/repos/owner/repo/issues/1/comments").mock( + side_effect=httpx.ConnectError("boom") + ) + with pytest.raises(httpx.ConnectError): + await client.create_issue_comment("owner", "repo", 1, "hi") + assert sleep_stub.calls == [] + + async def test_post_rate_limit_is_retried( + self, + mock_router: respx.MockRouter, + client: GitHubClient, + sleep_stub: RecordingSleep, + ) -> None: + route = mock_router.post("/repos/owner/repo/issues/1/comments") + route.side_effect = [ + httpx.Response(429, headers={"retry-after": "2"}, text="slow down"), + httpx.Response( + 201, + json={ + "id": 5, + "body": "hi", + "user": {"login": "octocat"}, + "html_url": "https://github.com/x", + }, + ), + ] + comment = await client.create_issue_comment("owner", "repo", 1, "hi") + assert comment.id == 5 + assert sleep_stub.calls == [2.0] + + class TestFromConfig: def test_threads_config_values(self) -> None: from opencode_github.config import Config