136 lines
4.8 KiB
Python
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)
|