Files
raycast-api/tests/test_retry.py
T

136 lines
4.8 KiB
Python

"""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)