Files
049-snack-and-lift/tests/unit/test_retry.py
Tirsvad 37f3cc436a Add retry wrapper and resilient login, booking and bookings read (step 9)
retry(func, retries=7, description=None), RetriesExhaustedError, resilient
login, book_class and get_my_bookings that re-read the card before a
repeat and count a booking only when the page confirms it, --retries option,
step-9 console image, seeded chaos tests at 50 percent failure and
container tests against failing local fixtures.

Task: MIL-007#1
Task: MIL-007#2
Task: MIL-007#3
Task: MIL-007#4
2026-10-28 18:14:13 +08:00

168 lines
4.8 KiB
Python

"""@file test_retry.py
@brief The retry wrapper: success, repeats, exhaustion, what it logs and what it never retries.
"""
import logging
import pytest
from snack_and_lift import constants
from snack_and_lift.application.retry import retry
from snack_and_lift.domain.errors import (
GymError,
LoginFailedError,
MissingCredentialsError,
PageFormatError,
RetriesExhaustedError,
UnknownButtonError,
)
class Flaky:
"""@brief A step that raises the given errors in turn and then returns a value."""
def __init__(self, errors: list[Exception], value: str = "done") -> None:
self._errors = list(errors)
self._value = value
self.calls = 0
def __call__(self) -> str:
self.calls += 1
if self._errors:
raise self._errors.pop(0)
return self._value
def test_retry_returns_the_result_on_the_first_attempt() -> None:
step = Flaky([])
sleeps: list[float] = []
assert retry(step, sleep=sleeps.append) == "done"
assert step.calls == 1
assert sleeps == []
def test_retry_succeeds_after_some_failures(caplog: pytest.LogCaptureFixture) -> None:
step = Flaky([GymError("a"), GymError("b")])
with caplog.at_level(logging.INFO):
result = retry(step, 7, "login", sleep=lambda _: None)
assert result == "done"
assert step.calls == 3
assert "✓ login succeeded after 3 attempts" in caplog.text
def test_retry_does_not_log_success_after_a_single_attempt(
caplog: pytest.LogCaptureFixture,
) -> None:
with caplog.at_level(logging.INFO):
retry(Flaky([]), 7, "login", sleep=lambda _: None)
assert "succeeded" not in caplog.text
def test_retry_raises_after_the_last_attempt_with_the_last_error_as_cause() -> None:
last = GymError("third")
step = Flaky([GymError("first"), GymError("second"), last])
with pytest.raises(RetriesExhaustedError, match="login failed after 3 attempts") as caught:
retry(step, 3, "login", sleep=lambda _: None)
assert caught.value.__cause__ is last
assert "third" in str(caught.value)
assert step.calls == 3
def test_retries_exhausted_is_a_gym_error() -> None:
with pytest.raises(GymError):
retry(Flaky([GymError("x")]), 1, sleep=lambda _: None)
def test_retry_with_one_attempt_does_not_repeat_or_sleep() -> None:
step = Flaky([GymError("x"), GymError("y")])
sleeps: list[float] = []
with pytest.raises(RetriesExhaustedError):
retry(step, 1, sleep=sleeps.append)
assert step.calls == 1
assert sleeps == []
def test_retry_with_one_attempt_returns_on_success() -> None:
assert retry(Flaky([]), 1, sleep=lambda _: None) == "done"
def test_retry_waits_the_delay_between_attempts_but_not_after_the_last() -> None:
sleeps: list[float] = []
with pytest.raises(RetriesExhaustedError):
retry(Flaky([GymError("x")] * 4), 4, delay=1.5, sleep=sleeps.append)
assert sleeps == [1.5, 1.5, 1.5]
def test_retry_uses_the_default_delay_and_attempts() -> None:
sleeps: list[float] = []
step = Flaky([GymError("x")] * constants.RETRY_COUNT)
with pytest.raises(RetriesExhaustedError):
retry(step, sleep=sleeps.append)
assert step.calls == constants.RETRY_COUNT
assert sleeps == [constants.RETRY_DELAY_SECONDS] * (constants.RETRY_COUNT - 1)
def test_retry_logs_a_warning_with_attempt_description_and_error(
caplog: pytest.LogCaptureFixture,
) -> None:
step = Flaky([GymError("Network request failed. Please try again.")])
with caplog.at_level(logging.WARNING):
retry(step, 7, "login", sleep=lambda _: None)
assert (
"⚠ Attempt 1/7 failed: login: Network request failed. Please try again. Retrying..."
in caplog.text
)
def test_retry_names_a_step_without_description(caplog: pytest.LogCaptureFixture) -> None:
with caplog.at_level(logging.WARNING):
retry(Flaky([GymError("x")]), sleep=lambda _: None)
assert f"failed: {constants.RETRY_DEFAULT_DESCRIPTION}: x" in caplog.text
@pytest.mark.parametrize(
"error",
[LoginFailedError("no"), UnknownButtonError("busy"), PageFormatError("format")],
)
def test_retry_repeats_every_kind_of_gym_error(error: GymError) -> None:
step = Flaky([error])
assert retry(step, 2, sleep=lambda _: None) == "done"
assert step.calls == 2
def test_retry_lets_other_errors_pass_untouched() -> None:
error = MissingCredentialsError("missing")
step = Flaky([error])
with pytest.raises(MissingCredentialsError) as caught:
retry(step, 7, sleep=lambda _: None)
assert caught.value is error
assert step.calls == 1
@pytest.mark.parametrize("retries", [0, -1])
def test_retry_rejects_fewer_than_one_attempt(retries: int) -> None:
step = Flaky([])
with pytest.raises(ValueError, match="at least 1"):
retry(step, retries)
assert step.calls == 0