"""Retry policy tests.""" from __future__ import annotations import email.utils import time import pytest from raycast_api.client.retry import ( DEFAULT_RETRY_STATUSES, RetryPolicy, parse_retry_after, ) class TestRetryStatuses: def test_default_includes_429_and_5xx(self) -> None: for code in (429, 500, 502, 503, 504): assert code in DEFAULT_RETRY_STATUSES def test_default_includes_transient_4xx(self) -> None: assert 408 in DEFAULT_RETRY_STATUSES assert 425 in DEFAULT_RETRY_STATUSES def test_default_excludes_permanent_errors(self) -> None: for code in (400, 401, 403, 404, 422): assert code not in DEFAULT_RETRY_STATUSES class TestShouldRetry: def test_retries_429(self) -> None: p = RetryPolicy(max_attempts=3) assert p.should_retry(attempt=1, status=429) is True assert p.should_retry(attempt=2, status=429) is True def test_stops_at_max_attempts(self) -> None: p = RetryPolicy(max_attempts=3) assert p.should_retry(attempt=3, status=429) is False assert p.should_retry(attempt=99, status=503) is False def test_does_not_retry_4xx_permanent(self) -> None: p = RetryPolicy() assert p.should_retry(attempt=1, status=400) is False assert p.should_retry(attempt=1, status=401) is False assert p.should_retry(attempt=1, status=404) is False def test_retries_custom_status_set(self) -> None: p = RetryPolicy(retry_statuses=frozenset({418})) assert p.should_retry(attempt=1, status=418) is True assert p.should_retry(attempt=1, status=503) is False class TestDelaySchedule: def test_exponential_backoff(self) -> None: p = RetryPolicy(initial_delay=1.0, multiplier=2.0, max_delay=60.0) assert p.delay_for_attempt(1) == 1.0 assert p.delay_for_attempt(2) == 2.0 assert p.delay_for_attempt(3) == 4.0 assert p.delay_for_attempt(4) == 8.0 def test_clamped_to_max_delay(self) -> None: p = RetryPolicy(initial_delay=10.0, multiplier=10.0, max_delay=20.0) assert p.delay_for_attempt(1) == 10.0 assert p.delay_for_attempt(2) == 20.0 assert p.delay_for_attempt(5) == 20.0 def test_retry_after_overrides_schedule(self) -> None: p = RetryPolicy(initial_delay=1.0, max_delay=60.0) assert p.delay_for_attempt(1, retry_after=7.0) == 7.0 assert p.delay_for_attempt(3, retry_after=2.5) == 2.5 def test_retry_after_clamped_to_max(self) -> None: p = RetryPolicy(max_delay=10.0) assert p.delay_for_attempt(1, retry_after=3600.0) == 10.0 def test_retry_after_ignored_when_disabled(self) -> None: p = RetryPolicy(initial_delay=1.0, respect_retry_after=False) assert p.delay_for_attempt(1, retry_after=999.0) == 1.0 def test_negative_retry_after_floors_to_zero(self) -> None: p = RetryPolicy() assert p.delay_for_attempt(1, retry_after=-5.0) == 0.0 class TestParseRetryAfter: def test_none_input_returns_none(self) -> None: assert parse_retry_after(None) is None assert parse_retry_after("") is None def test_integer_seconds(self) -> None: assert parse_retry_after("0") == 0.0 assert parse_retry_after("42") == 42.0 assert parse_retry_after(" 120 ") == 120.0 def test_float_seconds(self) -> None: assert parse_retry_after("2.5") == 2.5 def test_http_date(self) -> None: now = 1_700_000_000.0 future = now + 30 date_str = email.utils.format_datetime( __import__("datetime").datetime.fromtimestamp( future, tz=__import__("datetime").timezone.utc ) ) got = parse_retry_after(date_str, now=now) assert got is not None assert 29.0 <= got <= 31.0 def test_http_date_in_past_floors_to_zero(self) -> None: date_str = "Sun, 06 Nov 1994 08:49:37 GMT" assert parse_retry_after(date_str, now=time.time()) == 0.0 def test_garbage_returns_none(self) -> None: assert parse_retry_after("not a date") is None class TestNegativeShift: def test_zero_attempts_pre_increment_guard(self) -> None: p = RetryPolicy(max_attempts=1) assert p.should_retry(attempt=1, status=429) is False def test_default_policy_total_budget_is_under_30s() -> None: """Default config should not park the client for ages on a bad path.""" p = RetryPolicy() total = sum(p.delay_for_attempt(a) for a in range(1, p.max_attempts)) assert total < 30.0, f"default total backoff = {total}s, too long" @pytest.mark.parametrize("status", sorted(DEFAULT_RETRY_STATUSES)) def test_each_default_status_retryable_at_attempt_1(status: int) -> None: p = RetryPolicy(max_attempts=2) assert p.should_retry(attempt=1, status=status)