mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 18:33:15 +00:00
Applies the plan's C5 step: git mv every test per the domain-reorg
move-map (movemap-m50.txt) into tests/{agent,analyzer,auth,dashboard,
github,middleware,models,reviewer,sandbox,slack,tools,webhooks}/, plus
the 13 fork-only placements from the scoping report §2c (Atlassian
webhook tests -> tests/webhooks/, test_atlassian_connect.py and
test_auth_error_leak.py -> tests/auth/, jira/confluence util tests ->
tests/tools/, test_repo_binding_isolation.py -> tests/sandbox/,
bot-identity/autofix tests -> tests/github/).
Path-only move: the only content edits are parents[1] -> parents[2]
fixes in test_e2b_integration.py and test_daytona_integration.py,
required because their __file__-relative ROOT path gained one more
directory level in the move.
Monkeypatch retargets for these files were already completed in C4;
none remained outstanding here.
354 lines
12 KiB
Python
354 lines
12 KiB
Python
"""Unit tests for the shared GitHub HTTP helper."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from agent.utils.github_http import (
|
|
GITHUB_API_BASE,
|
|
GITHUB_GRAPHQL,
|
|
_compute_backoff,
|
|
_is_retryable_response,
|
|
_is_secondary_rate_limit,
|
|
_retry_after_seconds,
|
|
github_client,
|
|
github_headers,
|
|
github_request,
|
|
)
|
|
|
|
|
|
def test_github_headers_returns_standard_headers() -> None:
|
|
headers = github_headers("mytoken")
|
|
assert headers["Authorization"] == "Bearer mytoken"
|
|
assert headers["Accept"] == "application/vnd.github+json"
|
|
assert headers["X-GitHub-Api-Version"] == "2022-11-28"
|
|
|
|
|
|
def test_github_constants() -> None:
|
|
assert GITHUB_API_BASE == "https://api.github.com"
|
|
assert GITHUB_GRAPHQL == "https://api.github.com/graphql"
|
|
|
|
|
|
def _make_response(status_code: int, headers: dict[str, str] | None = None) -> httpx.Response:
|
|
return httpx.Response(status_code, headers=headers or {})
|
|
|
|
|
|
class TestIsSecondaryRateLimit:
|
|
def test_403_with_rate_limit_remaining_zero(self) -> None:
|
|
resp = _make_response(403, {"X-RateLimit-Remaining": "0"})
|
|
assert _is_secondary_rate_limit(resp)
|
|
|
|
def test_403_with_secondary_rate_limit_body(self) -> None:
|
|
resp = httpx.Response(403, text="You have exceeded a secondary rate limit")
|
|
assert _is_secondary_rate_limit(resp)
|
|
|
|
def test_403_with_rate_limit_body(self) -> None:
|
|
resp = httpx.Response(403, text="API rate limit exceeded")
|
|
assert _is_secondary_rate_limit(resp)
|
|
|
|
def test_403_without_rate_limit_indicators(self) -> None:
|
|
resp = httpx.Response(403, text="Forbidden")
|
|
assert not _is_secondary_rate_limit(resp)
|
|
|
|
def test_non_403_not_secondary_rate_limit(self) -> None:
|
|
resp = _make_response(429, {"X-RateLimit-Remaining": "0"})
|
|
assert not _is_secondary_rate_limit(resp)
|
|
|
|
|
|
class TestIsRetryableResponse:
|
|
@pytest.mark.parametrize("status", [429, 503])
|
|
def test_always_retryable_status_codes(self, status: int) -> None:
|
|
assert _is_retryable_response(_make_response(status), "POST")
|
|
assert _is_retryable_response(_make_response(status), "GET")
|
|
|
|
@pytest.mark.parametrize("status", [502, 504])
|
|
def test_idempotent_only_retryable_status_codes(self, status: int) -> None:
|
|
assert _is_retryable_response(_make_response(status), "GET")
|
|
assert not _is_retryable_response(_make_response(status), "POST")
|
|
|
|
def test_secondary_rate_limit_is_retryable(self) -> None:
|
|
resp = httpx.Response(403, text="secondary rate limit")
|
|
assert _is_retryable_response(resp, "POST")
|
|
assert _is_retryable_response(resp, "GET")
|
|
|
|
def test_200_not_retryable(self) -> None:
|
|
assert not _is_retryable_response(_make_response(200), "GET")
|
|
|
|
def test_404_not_retryable(self) -> None:
|
|
assert not _is_retryable_response(_make_response(404), "GET")
|
|
|
|
def test_422_not_retryable(self) -> None:
|
|
assert not _is_retryable_response(_make_response(422), "POST")
|
|
|
|
|
|
class TestRetryAfterSeconds:
|
|
def test_valid_retry_after(self) -> None:
|
|
resp = _make_response(429, {"Retry-After": "30"})
|
|
assert _retry_after_seconds(resp) == 30.0
|
|
|
|
def test_no_retry_after_header(self) -> None:
|
|
resp = _make_response(429)
|
|
assert _retry_after_seconds(resp) is None
|
|
|
|
def test_invalid_retry_after(self) -> None:
|
|
resp = _make_response(429, {"Retry-After": "not-a-number"})
|
|
assert _retry_after_seconds(resp) is None
|
|
|
|
|
|
class TestComputeBackoff:
|
|
def test_uses_retry_after_when_present(self) -> None:
|
|
resp = _make_response(429, {"Retry-After": "15"})
|
|
delay = _compute_backoff(resp, attempt=0)
|
|
assert delay == 15.0
|
|
|
|
def test_caps_retry_after_at_max(self) -> None:
|
|
resp = _make_response(429, {"Retry-After": "120"})
|
|
delay = _compute_backoff(resp, attempt=0)
|
|
assert delay <= 60.0
|
|
|
|
def test_exponential_backoff_without_response(self) -> None:
|
|
delay = _compute_backoff(None, attempt=0)
|
|
assert 0.75 <= delay <= 1.25
|
|
|
|
def test_exponential_backoff_attempt_2(self) -> None:
|
|
delay = _compute_backoff(None, attempt=2)
|
|
base = 1.0 * (2**2)
|
|
assert base - base * 0.25 <= delay <= base + base * 0.25
|
|
|
|
def test_backoff_capped_at_max(self) -> None:
|
|
delay = _compute_backoff(None, attempt=10)
|
|
assert delay <= 60.0
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_client_yields_client_with_token_headers() -> None:
|
|
async with github_client(token="testtoken") as client:
|
|
assert isinstance(client, httpx.AsyncClient)
|
|
assert client.headers["Authorization"] == "Bearer testtoken"
|
|
assert client.headers["Accept"] == "application/vnd.github+json"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_client_without_token() -> None:
|
|
async with github_client() as client:
|
|
assert isinstance(client, httpx.AsyncClient)
|
|
assert "Authorization" not in client.headers
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_request_retries_on_429() -> None:
|
|
responses = [
|
|
_make_response(429, {"Retry-After": "0"}),
|
|
_make_response(200),
|
|
]
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(side_effect=responses)
|
|
|
|
with patch("agent.utils.github_http.asyncio.sleep", new_callable=AsyncMock):
|
|
response = await github_request(client, "GET", "https://api.github.com/test")
|
|
|
|
assert response.status_code == 200
|
|
assert client.get.await_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_request_retries_on_503() -> None:
|
|
responses = [
|
|
_make_response(503),
|
|
_make_response(200),
|
|
]
|
|
client = AsyncMock()
|
|
client.post = AsyncMock(side_effect=responses)
|
|
|
|
with patch("agent.utils.github_http.asyncio.sleep", new_callable=AsyncMock):
|
|
response = await github_request(client, "POST", "https://api.github.com/test")
|
|
|
|
assert response.status_code == 200
|
|
assert client.post.await_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_request_retries_on_secondary_rate_limit() -> None:
|
|
responses = [
|
|
httpx.Response(403, text="secondary rate limit", headers={}),
|
|
_make_response(200),
|
|
]
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(side_effect=responses)
|
|
|
|
with patch("agent.utils.github_http.asyncio.sleep", new_callable=AsyncMock):
|
|
response = await github_request(client, "GET", "https://api.github.com/test")
|
|
|
|
assert response.status_code == 200
|
|
assert client.get.await_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_request_does_not_retry_on_404() -> None:
|
|
response_404 = _make_response(404)
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(return_value=response_404)
|
|
|
|
response = await github_request(client, "GET", "https://api.github.com/test")
|
|
|
|
assert response.status_code == 404
|
|
assert client.get.await_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_request_does_not_retry_on_422() -> None:
|
|
response_422 = _make_response(422)
|
|
client = AsyncMock()
|
|
client.post = AsyncMock(return_value=response_422)
|
|
|
|
response = await github_request(client, "POST", "https://api.github.com/test")
|
|
|
|
assert response.status_code == 422
|
|
assert client.post.await_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_request_gives_up_after_max_retries() -> None:
|
|
response_429 = _make_response(429, {"Retry-After": "0"})
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(return_value=response_429)
|
|
|
|
with patch("agent.utils.github_http.asyncio.sleep", new_callable=AsyncMock):
|
|
response = await github_request(client, "GET", "https://api.github.com/test", max_retries=2)
|
|
|
|
assert response.status_code == 429
|
|
assert client.get.await_count == 3
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_request_retries_on_timeout() -> None:
|
|
responses = [
|
|
httpx.TimeoutException("timeout"),
|
|
_make_response(200),
|
|
]
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(side_effect=responses)
|
|
|
|
with patch("agent.utils.github_http.asyncio.sleep", new_callable=AsyncMock):
|
|
response = await github_request(client, "GET", "https://api.github.com/test")
|
|
|
|
assert response.status_code == 200
|
|
assert client.get.await_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_request_does_not_retry_transport_error_on_post() -> None:
|
|
"""Transport errors on POST must not retry — the server may have already
|
|
processed the write, and retrying would duplicate the resource."""
|
|
client = AsyncMock()
|
|
client.post = AsyncMock(side_effect=httpx.TimeoutException("timeout"))
|
|
|
|
with pytest.raises(httpx.TimeoutException):
|
|
await github_request(client, "POST", "https://api.github.com/test")
|
|
|
|
assert client.post.await_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_request_retries_on_429_even_for_post() -> None:
|
|
"""429 is safe to retry for any method — the server explicitly did not
|
|
process the request."""
|
|
responses = [
|
|
_make_response(429, {"Retry-After": "0"}),
|
|
_make_response(201),
|
|
]
|
|
client = AsyncMock()
|
|
client.post = AsyncMock(side_effect=responses)
|
|
|
|
with patch("agent.utils.github_http.asyncio.sleep", new_callable=AsyncMock):
|
|
response = await github_request(client, "POST", "https://api.github.com/test")
|
|
|
|
assert response.status_code == 201
|
|
assert client.post.await_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_request_retries_on_503_even_for_post() -> None:
|
|
responses = [
|
|
_make_response(503),
|
|
_make_response(201),
|
|
]
|
|
client = AsyncMock()
|
|
client.post = AsyncMock(side_effect=responses)
|
|
|
|
with patch("agent.utils.github_http.asyncio.sleep", new_callable=AsyncMock):
|
|
response = await github_request(client, "POST", "https://api.github.com/test")
|
|
|
|
assert response.status_code == 201
|
|
assert client.post.await_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_request_does_not_retry_502_on_post() -> None:
|
|
"""502 is ambiguous — the upstream may have processed the write before the
|
|
gateway returned an error. Must not retry for non-idempotent methods."""
|
|
response_502 = _make_response(502)
|
|
client = AsyncMock()
|
|
client.post = AsyncMock(return_value=response_502)
|
|
|
|
response = await github_request(client, "POST", "https://api.github.com/test")
|
|
|
|
assert response.status_code == 502
|
|
assert client.post.await_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_request_does_not_retry_504_on_post() -> None:
|
|
"""504 is ambiguous — the upstream may have processed the write before the
|
|
gateway timed out. Must not retry for non-idempotent methods."""
|
|
response_504 = _make_response(504)
|
|
client = AsyncMock()
|
|
client.post = AsyncMock(return_value=response_504)
|
|
|
|
response = await github_request(client, "POST", "https://api.github.com/test")
|
|
|
|
assert response.status_code == 504
|
|
assert client.post.await_count == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_request_retries_502_on_get() -> None:
|
|
"""502 is safe to retry for idempotent methods."""
|
|
responses = [
|
|
_make_response(502),
|
|
_make_response(200),
|
|
]
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(side_effect=responses)
|
|
|
|
with patch("agent.utils.github_http.asyncio.sleep", new_callable=AsyncMock):
|
|
response = await github_request(client, "GET", "https://api.github.com/test")
|
|
|
|
assert response.status_code == 200
|
|
assert client.get.await_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_request_raises_after_exhausting_transport_retries() -> None:
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(side_effect=httpx.ConnectTimeout("timeout"))
|
|
|
|
with patch("agent.utils.github_http.asyncio.sleep", new_callable=AsyncMock):
|
|
with pytest.raises(httpx.ConnectTimeout):
|
|
await github_request(client, "GET", "https://api.github.com/test", max_retries=1)
|
|
|
|
assert client.get.await_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_github_request_propagates_non_retryable_http_error() -> None:
|
|
client = AsyncMock()
|
|
client.get = AsyncMock(side_effect=httpx.HTTPError("boom"))
|
|
|
|
with pytest.raises(httpx.HTTPError):
|
|
await github_request(client, "GET", "https://api.github.com/test")
|
|
|
|
assert client.get.await_count == 1
|