GRM-51: fix: API resilience with retry, idempotent releases, and graceful Vikunja errors
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
"""Unit tests for api_clients module."""
|
||||
|
||||
import http
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
from gitea_runner_manager.api_clients import GiteaClient, VikunjaClient, _parse_error
|
||||
from gitea_runner_manager.api_clients import GiteaClient, VikunjaClient, _is_retryable, _parse_error
|
||||
from gitea_runner_manager.config import (
|
||||
BRANCH_PROTECTION_CONFIG,
|
||||
DEFAULT_PER_PAGE,
|
||||
@@ -25,6 +25,15 @@ def _mock_response(json_data: object | None = None, raise_on_status: bool = Fals
|
||||
return mock
|
||||
|
||||
|
||||
def _mock_http_error(status_code: int, message: str = "") -> requests.HTTPError:
|
||||
"""Create an HTTPError with a proper response attached (for _parse_error)."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = status_code
|
||||
resp.json.return_value = {"message": message or str(status_code)}
|
||||
err = requests.HTTPError(f"{status_code} {message}", response=resp)
|
||||
return err
|
||||
|
||||
|
||||
class TestParseError:
|
||||
def test_json_parse_fallback(self) -> None:
|
||||
mock_response = MagicMock()
|
||||
@@ -364,6 +373,115 @@ class TestGiteaClient:
|
||||
json={"tag_name": "v1.0.0", "name": "v1.0.0", "body": "", "draft": False, "prerelease": False},
|
||||
)
|
||||
|
||||
def test_get_release_by_tag_found(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(return_value=_mock_response({"id": 1, "tag_name": "v1.0.0"}))
|
||||
result = client.get_release_by_tag("v1.0.0")
|
||||
assert result is not None
|
||||
assert result["id"] == 1
|
||||
|
||||
def test_get_release_by_tag_not_found(self) -> None:
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.side_effect = requests.HTTPError("404")
|
||||
mock_resp.status_code = 404
|
||||
client._session.request = MagicMock(return_value=mock_resp)
|
||||
result = client.get_release_by_tag("v9.9.9")
|
||||
assert result is None
|
||||
|
||||
def test_create_release_idempotent_existing(self) -> None:
|
||||
"""If release already exists, should return it without creating a new one."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
existing_response = _mock_response({"id": 42, "tag_name": "v1.0.0"})
|
||||
client._session.request = MagicMock(return_value=existing_response)
|
||||
result = client.create_release_idempotent("v1.0.0")
|
||||
assert result["id"] == 42
|
||||
# Should only call GET (check), not POST (create)
|
||||
assert client._session.request.call_count == 1
|
||||
assert client._session.request.call_args[0][0] == "GET"
|
||||
|
||||
def test_create_release_idempotent_new(self) -> None:
|
||||
"""If release doesn't exist, should create it."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
not_found_resp = MagicMock()
|
||||
not_found_resp.raise_for_status.side_effect = requests.HTTPError("404")
|
||||
not_found_resp.status_code = 404
|
||||
create_resp = _mock_response({"id": 1, "tag_name": "v1.0.0"})
|
||||
client._session.request = MagicMock(side_effect=[not_found_resp, create_resp])
|
||||
result = client.create_release_idempotent("v1.0.0")
|
||||
assert result["id"] == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_retries_on_429(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on 429 rate limit with exponential backoff."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
rate_limited = MagicMock()
|
||||
rate_limited.raise_for_status.side_effect = _mock_http_error(429, "rate limited")
|
||||
success = _mock_response({"ok": True})
|
||||
client._session.request = MagicMock(side_effect=[rate_limited, rate_limited, success])
|
||||
result = client._request("GET", "/test")
|
||||
assert result.json() == {"ok": True}
|
||||
assert client._session.request.call_count == 3
|
||||
assert mock_sleep.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_retries_on_503(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on 503 service unavailable."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
unavailable = MagicMock()
|
||||
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
|
||||
success = _mock_response({"ok": True})
|
||||
client._session.request = MagicMock(side_effect=[unavailable, success])
|
||||
result = client._request("GET", "/test")
|
||||
assert result.json() == {"ok": True}
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_no_retry_on_404(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should NOT retry on 404 — it's not a transient error."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
not_found = MagicMock()
|
||||
not_found.raise_for_status.side_effect = _mock_http_error(404, "not found")
|
||||
client._session.request = MagicMock(return_value=not_found)
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client._request("GET", "/test")
|
||||
assert exc_info.value.status == 404
|
||||
assert client._session.request.call_count == 1
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should retry on connection errors."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
success = _mock_response({"ok": True})
|
||||
client._session.request = MagicMock(side_effect=[requests.ConnectionError("refused"), success])
|
||||
result = client._request("GET", "/test")
|
||||
assert result.json() == {"ok": True}
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should raise APIError after max retries on persistent 503."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
unavailable = MagicMock()
|
||||
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
|
||||
client._session.request = MagicMock(return_value=unavailable)
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client._request("GET", "/test")
|
||||
assert exc_info.value.status == 503
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_request_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""Should raise APIError after max retries on persistent connection errors."""
|
||||
client = GiteaClient("https://git.example.com", "tok", "owner", "repo")
|
||||
client._session.request = MagicMock(side_effect=requests.ConnectionError("refused"))
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client._request("GET", "/test")
|
||||
assert exc_info.value.status == 0
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
|
||||
class TestVikunjaClient:
|
||||
def test_init_sets_headers(self) -> None:
|
||||
@@ -440,7 +558,9 @@ class TestVikunjaClient:
|
||||
|
||||
def test_http_error_raises_api_error(self) -> None:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(return_value=_mock_response(raise_on_status=True))
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.side_effect = _mock_http_error(http.HTTPStatus.INTERNAL_SERVER_ERROR, "server error")
|
||||
client._session.request = MagicMock(return_value=mock_resp)
|
||||
|
||||
with pytest.raises(APIError):
|
||||
client.list_tasks()
|
||||
@@ -449,8 +569,73 @@ class TestVikunjaClient:
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
err = requests.HTTPError("connection failed")
|
||||
err.response = None # type: ignore[assignment]
|
||||
client._session.request = MagicMock(side_effect=err)
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.raise_for_status.side_effect = err
|
||||
client._session.request = MagicMock(return_value=mock_resp)
|
||||
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client.list_tasks()
|
||||
assert "connection failed" in str(exc_info.value)
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_vikunja_retries_on_503(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should also retry on 503."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
unavailable = MagicMock()
|
||||
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
|
||||
success = _mock_response([{"id": 1}])
|
||||
client._session.request = MagicMock(side_effect=[unavailable, success])
|
||||
result = client.list_tasks()
|
||||
assert len(result) == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_vikunja_retries_on_connection_error(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should retry on connection errors."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
success = _mock_response([{"id": 1}])
|
||||
client._session.request = MagicMock(side_effect=[requests.ConnectionError("refused"), success])
|
||||
result = client.list_tasks()
|
||||
assert len(result) == 1
|
||||
assert client._session.request.call_count == 2
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_vikunja_max_retries_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should raise APIError after max retries on persistent 503."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
unavailable = MagicMock()
|
||||
unavailable.raise_for_status.side_effect = _mock_http_error(503, "unavailable")
|
||||
client._session.request = MagicMock(return_value=unavailable)
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client.list_tasks()
|
||||
assert exc_info.value.status == 503
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
@patch("gitea_runner_manager.api_clients.time.sleep")
|
||||
def test_vikunja_connection_error_exhausted(self, mock_sleep: MagicMock) -> None:
|
||||
"""VikunjaClient should raise APIError after max retries on persistent connection errors."""
|
||||
client = VikunjaClient("https://work.example.com", "tok")
|
||||
client._session.request = MagicMock(side_effect=requests.ConnectionError("refused"))
|
||||
with pytest.raises(APIError) as exc_info:
|
||||
client.list_tasks()
|
||||
assert exc_info.value.status == 0
|
||||
assert client._session.request.call_count == 3 # MAX_RETRIES
|
||||
|
||||
|
||||
class TestIsRetryable:
|
||||
def test_connection_error_is_retryable(self) -> None:
|
||||
assert _is_retryable(requests.ConnectionError("refused")) is True
|
||||
|
||||
def test_timeout_is_retryable(self) -> None:
|
||||
assert _is_retryable(requests.Timeout("timed out")) is True
|
||||
|
||||
def test_429_is_retryable(self) -> None:
|
||||
err = _mock_http_error(429, "rate limited")
|
||||
assert _is_retryable(err) is True
|
||||
|
||||
def test_404_is_not_retryable(self) -> None:
|
||||
err = _mock_http_error(404, "not found")
|
||||
assert _is_retryable(err) is False
|
||||
|
||||
def test_generic_exception_is_not_retryable(self) -> None:
|
||||
assert _is_retryable(ValueError("oops")) is False
|
||||
|
||||
Reference in New Issue
Block a user