diff --git a/src/gitea_dashboard/client.py b/src/gitea_dashboard/client.py index 6965c55..04210ad 100644 --- a/src/gitea_dashboard/client.py +++ b/src/gitea_dashboard/client.py @@ -59,7 +59,17 @@ class GiteaClient: if attempt < self._MAX_RETRIES: retry_after = resp.headers.get("Retry-After") if retry_after is not None: - delay = float(retry_after) + try: + # Cap a 30s pour eviter un blocage indefini. + # max() assure un plancher au backoff lineaire + # (protege contre Retry-After: 0 ou negatif). + delay = min(float(retry_after), 30.0) + delay = max(delay, self._RETRY_DELAY) + except (ValueError, TypeError): + # Retry-After peut etre une date HTTP RFC 7231 + # (ex: "Wed, 21 Oct 2025 07:28:00 GMT") : + # on retombe sur le backoff lineaire standard. + delay = self._RETRY_DELAY * (attempt + 1) else: delay = self._RETRY_DELAY * (attempt + 1) time.sleep(delay) diff --git a/tests/test_cli.py b/tests/test_cli.py index 6442d55..c910051 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -330,6 +330,25 @@ class TestMainHealth: assert exc_info.value.code == 1 + @patch("gitea_dashboard.cli.GiteaClient") + def test_main_health_version_ok_repos_fail(self, mock_client_cls): + """--health : get_version reussit mais get_repos leve HTTPError -> exit 1. + + Verifie le cas d'un health check partiel : l'instance Gitea repond + sur /version mais l'acces aux repos echoue (ex: token sans permissions). + """ + env = {"GITEA_TOKEN": "test-token"} + mock_client = MagicMock() + mock_client_cls.return_value = mock_client + mock_client.get_version.return_value = {"version": "1.21.0"} + mock_client.get_repos.side_effect = requests.HTTPError("403 Forbidden") + + with patch.dict("os.environ", env, clear=True): + with pytest.raises(SystemExit) as exc_info: + main(["--health"]) + + assert exc_info.value.code == 1 + class TestMainNoDesc: """Test main() with --no-desc.""" diff --git a/tests/test_client.py b/tests/test_client.py index abc9611..7969e53 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -269,6 +269,49 @@ class TestGetWithRetry429: assert mock_sleep.call_count == 2 + @patch("time.sleep") + def test_retry_after_http_date_falls_back_to_backoff(self, mock_sleep): + """Retry-After contenant une date HTTP RFC 7231 (non-numerique) : + le parsing echoue silencieusement et on retombe sur le backoff lineaire.""" + client = self._make_client() + # Valeur realiste envoyee par certains serveurs + resp_429 = self._make_429_response(retry_after="Wed, 21 Oct 2025 07:28:00 GMT") + 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 + # Backoff lineaire : attempt=0 → 1 * 1.0 = 1.0s + mock_sleep.assert_called_once_with(1.0) + + @patch("time.sleep") + def test_retry_after_zero_uses_floor(self, mock_sleep): + """Retry-After: 0 ne provoque pas un retry immediat sans backoff. + Le plancher (_RETRY_DELAY = 1.0s) est applique.""" + client = self._make_client() + resp_429 = self._make_429_response(retry_after="0") + 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) # plancher _RETRY_DELAY + + @patch("time.sleep") + def test_retry_after_huge_value_capped_at_30s(self, mock_sleep): + """Retry-After avec une valeur enorme est plafonne a 30s.""" + client = self._make_client() + resp_429 = self._make_429_response(retry_after="3600") # 1 heure + 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(30.0) # cap a 30s + @patch("time.sleep") def test_retry_on_429_then_timeout(self, mock_sleep): """429 followed by Timeout: both retry types handled in same loop."""