Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion src/opencode_github/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__ = [
Expand All @@ -32,6 +38,7 @@
"LearningChallenge",
"ParsedCommand",
"PullRequest",
"RateLimitError",
"WebhookEvent",
"extract_commands",
"is_command_comment",
Expand Down
19 changes: 19 additions & 0 deletions src/opencode_github/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -53,11 +55,28 @@ 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,
model=model,
github_api_url=github_api_url,
allowed_commands=allowed_commands,
request_timeout=request_timeout,
max_retries=max_retries,
backoff_factor=backoff_factor,
)
173 changes: 167 additions & 6 deletions src/opencode_github/github_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,25 @@

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})

# 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:
Expand Down Expand Up @@ -38,6 +52,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.

Expand All @@ -56,9 +97,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={
Expand All @@ -69,6 +118,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()

Expand All @@ -78,13 +143,109 @@ 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()
# 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 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:
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 retry_transient and 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()
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.

async def get_pull_request(self, owner: str, repo: str, number: int) -> PullRequest:
data = await self._request("GET", f"/repos/{owner}/{repo}/pulls/{number}")
Expand Down
32 changes: 32 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading