fix(client): validate Retry-After header (cap, fallback, edge cases)

- Ajoute try/except autour du float() pour gérer les dates HTTP RFC 7231
- Cap à 30s pour éviter un blocage indéfini sur valeur énorme
- Plancher à _RETRY_DELAY pour Retry-After: 0 ou négatif (FINDING-R2)
- 4 nouveaux tests : date HTTP, valeur zéro, valeur énorme, health check partiel

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sylvain
2026-03-12 19:44:39 +01:00
parent 15ed533d20
commit 16344bbb3f
3 changed files with 73 additions and 1 deletions

View File

@@ -59,7 +59,17 @@ class GiteaClient:
if attempt < self._MAX_RETRIES: if attempt < self._MAX_RETRIES:
retry_after = resp.headers.get("Retry-After") retry_after = resp.headers.get("Retry-After")
if retry_after is not None: 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: else:
delay = self._RETRY_DELAY * (attempt + 1) delay = self._RETRY_DELAY * (attempt + 1)
time.sleep(delay) time.sleep(delay)

View File

@@ -330,6 +330,25 @@ class TestMainHealth:
assert exc_info.value.code == 1 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: class TestMainNoDesc:
"""Test main() with --no-desc.""" """Test main() with --no-desc."""

View File

@@ -269,6 +269,49 @@ class TestGetWithRetry429:
assert mock_sleep.call_count == 2 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") @patch("time.sleep")
def test_retry_on_429_then_timeout(self, mock_sleep): def test_retry_on_429_then_timeout(self, mock_sleep):
"""429 followed by Timeout: both retry types handled in same loop.""" """429 followed by Timeout: both retry types handled in same loop."""