fix(client,exporter): handle HTTP 429 retry and sanitize JSON
_get_with_retry now retries on HTTP 429 responses, respecting the Retry-After header when present. exporter sanitizes control characters (0x00-0x1F except \n \r \t) in text fields before JSON serialization. fixes #11 fixes #12 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -210,6 +210,83 @@ class TestGetWithRetry:
|
||||
mock_sleep.assert_any_call(2.0)
|
||||
|
||||
|
||||
class TestGetWithRetry429:
|
||||
"""Test _get_with_retry method (retry on HTTP 429 rate limiting)."""
|
||||
|
||||
def _make_client(self):
|
||||
return GiteaClient("http://gitea.local:3000", "tok")
|
||||
|
||||
def _make_429_response(self, retry_after=None):
|
||||
"""Create a mock 429 response."""
|
||||
resp = MagicMock()
|
||||
resp.status_code = 429
|
||||
resp.headers = {"Retry-After": retry_after} if retry_after is not None else {}
|
||||
resp.raise_for_status.side_effect = requests.HTTPError(
|
||||
"429 Too Many Requests", response=resp
|
||||
)
|
||||
return resp
|
||||
|
||||
def _make_200_response(self):
|
||||
resp = MagicMock()
|
||||
resp.status_code = 200
|
||||
return resp
|
||||
|
||||
@patch("time.sleep")
|
||||
def test_retry_on_429_with_retry_after(self, mock_sleep):
|
||||
"""429 with Retry-After header: sleeps for the indicated duration, then succeeds."""
|
||||
client = self._make_client()
|
||||
resp_429 = self._make_429_response(retry_after="2")
|
||||
resp_200 = self._make_200_response()
|
||||
|
||||
with patch.object(client.session, "get", side_effect=[resp_429, resp_200]):
|
||||
result = client._get_with_retry("http://gitea.local:3000/api/v1/test")
|
||||
|
||||
assert result.status_code == 200
|
||||
mock_sleep.assert_called_once_with(2.0)
|
||||
|
||||
@patch("time.sleep")
|
||||
def test_retry_on_429_without_retry_after(self, mock_sleep):
|
||||
"""429 without Retry-After header: uses linear backoff (1.0s for first retry)."""
|
||||
client = self._make_client()
|
||||
resp_429 = self._make_429_response()
|
||||
resp_200 = self._make_200_response()
|
||||
|
||||
with patch.object(client.session, "get", side_effect=[resp_429, resp_200]):
|
||||
result = client._get_with_retry("http://gitea.local:3000/api/v1/test")
|
||||
|
||||
assert result.status_code == 200
|
||||
mock_sleep.assert_called_once_with(1.0)
|
||||
|
||||
@patch("time.sleep")
|
||||
def test_retry_on_429_exhausted(self, mock_sleep):
|
||||
"""3 consecutive 429 responses: raises HTTPError after exhausting retries."""
|
||||
client = self._make_client()
|
||||
resp_429 = self._make_429_response()
|
||||
|
||||
with patch.object(client.session, "get", return_value=resp_429):
|
||||
with pytest.raises(requests.HTTPError):
|
||||
client._get_with_retry("http://gitea.local:3000/api/v1/test")
|
||||
|
||||
assert mock_sleep.call_count == 2
|
||||
|
||||
@patch("time.sleep")
|
||||
def test_retry_on_429_then_timeout(self, mock_sleep):
|
||||
"""429 followed by Timeout: both retry types handled in same loop."""
|
||||
client = self._make_client()
|
||||
resp_429 = self._make_429_response()
|
||||
resp_200 = self._make_200_response()
|
||||
|
||||
with patch.object(
|
||||
client.session,
|
||||
"get",
|
||||
side_effect=[resp_429, requests.Timeout("timeout"), resp_200],
|
||||
):
|
||||
result = client._get_with_retry("http://gitea.local:3000/api/v1/test")
|
||||
|
||||
assert result.status_code == 200
|
||||
assert mock_sleep.call_count == 2
|
||||
|
||||
|
||||
class TestGetLatestCommit:
|
||||
"""Test get_latest_commit method."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user