Files
webber/webber-api/tests/test_retry.py
T
jpmschweitzerandClaude eb3467d06a fix(webber-api): clear ruff, and two things it was pointing at
97 findings to zero. Most were mechanical — 52 unsorted import blocks, 10
unsorted __all__, assorted pyupgrade and simplify hints. Two were not, and both
were visible only because the lint made me look.

`webber version` did not exist. src/cli/commands/version.py defines
show_version(), main.py imported it, and the registration line was never
written — the CLI exposed chat and explore only. The import carried
`# noqa: F401`, which is what kept the omission quiet: someone marked the
symptom as intentional instead of asking why it was unused. show_version is not
redundant with the --version flag; it prints the resolved Ollama URL, model and
debug state, which is the form worth having when something is misconfigured.
Registered, and the suppression dropped because the import is now genuinely used.

test_spawn_explore_agent asserted nothing. It built a mock RunContext, patched
get_agent, and stopped at the comment "For now, verify the explore agent would
be called correctly". It had been counted as a passing test. An AST sweep of all
238 test functions found it was the only one, which is worth knowing — the
problem was contained, not systemic. It is now skipped with a reason, so it
reports as unfinished rather than as passing. Reducing it rather than deleting
its imports was the point: tidying the imports would have made a hollow test
look clean.

Two findings were false positives, and both are recorded rather than silently
worked around:

B023 flagged run_agent closing over full_prompt and ctx. Traced: agent_task is
awaited at line 326 before `continue` reaches the next iteration, so neither
name can be rebound while the closure is pending, and the exception path
cancels and awaits too. Not a bug. Bound as defaults anyway, because that stays
true if the await ever moves. I had called it a live bug before tracing it,
which is the mistake Rule 5 exists for.

RUF012 flagged `rules: list[ApprovalRule] = []` on ApprovalRuleSet. Its
suggested fix — annotate ClassVar — would remove the field from the model.
ApprovalRuleSet is a pydantic model and pydantic deep-copies defaults per
instance; verified by constructing two and confirming their lists are distinct
objects. Suppressed with that evidence in the comment. Ruff cannot see the
pydantic base because BaseSchema is a local subclass of BaseModel.

Also moved a stray `from src.shared.logging import ...` that had drifted below
a function definition, and merged a nested if in the ollama provider.

215 passed, 23 skipped, unchanged except for the new skip. `webber version`
exercised end to end.

mypy is NOT addressed here and the gate still fails on it — 55 errors in 14
files, 35 of them no-any-return from pydantic_ai's untyped returns. That was
hidden behind ruff, because the gate stops at the first failing stage.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-11 15:05:02 +02:00

244 lines
7.7 KiB
Python

"""
Tests for retry utilities.
"""
import httpx
import pytest
from src.shared.retry import (
calculate_backoff,
is_retryable_exception,
is_retryable_http_status,
retry_async,
with_retry,
)
class TestIsRetryableHttpStatus:
"""Tests for HTTP status code checking."""
def test_429_is_retryable(self):
"""429 Too Many Requests should be retryable."""
assert is_retryable_http_status(429) is True
def test_500_is_retryable(self):
"""500 Internal Server Error should be retryable."""
assert is_retryable_http_status(500) is True
def test_502_is_retryable(self):
"""502 Bad Gateway should be retryable."""
assert is_retryable_http_status(502) is True
def test_503_is_retryable(self):
"""503 Service Unavailable should be retryable."""
assert is_retryable_http_status(503) is True
def test_504_is_retryable(self):
"""504 Gateway Timeout should be retryable."""
assert is_retryable_http_status(504) is True
def test_400_not_retryable(self):
"""400 Bad Request should not be retryable."""
assert is_retryable_http_status(400) is False
def test_401_not_retryable(self):
"""401 Unauthorized should not be retryable."""
assert is_retryable_http_status(401) is False
def test_404_not_retryable(self):
"""404 Not Found should not be retryable."""
assert is_retryable_http_status(404) is False
def test_200_not_retryable(self):
"""200 OK should not be retryable."""
assert is_retryable_http_status(200) is False
class TestIsRetryableException:
"""Tests for exception checking."""
def test_timeout_exception_is_retryable(self):
"""Timeout exceptions should be retryable."""
exc = httpx.TimeoutException("timeout")
assert is_retryable_exception(exc) is True
def test_connect_error_is_retryable(self):
"""Connection errors should be retryable."""
exc = httpx.ConnectError("connection failed")
assert is_retryable_exception(exc) is True
def test_connection_error_is_retryable(self):
"""Python ConnectionError should be retryable."""
exc = ConnectionError("connection refused")
assert is_retryable_exception(exc) is True
def test_timeout_error_is_retryable(self):
"""Python TimeoutError should be retryable."""
exc = TimeoutError("timed out")
assert is_retryable_exception(exc) is True
def test_value_error_not_retryable(self):
"""ValueError should not be retryable."""
exc = ValueError("invalid value")
assert is_retryable_exception(exc) is False
def test_key_error_not_retryable(self):
"""KeyError should not be retryable."""
exc = KeyError("missing key")
assert is_retryable_exception(exc) is False
class TestCalculateBackoff:
"""Tests for backoff calculation."""
def test_first_attempt_base_delay(self):
"""First attempt should use base delay."""
delay = calculate_backoff(0, base_delay=1.0, jitter=False)
assert delay == 1.0
def test_second_attempt_doubles(self):
"""Second attempt should double the delay."""
delay = calculate_backoff(1, base_delay=1.0, jitter=False)
assert delay == 2.0
def test_third_attempt_quadruples(self):
"""Third attempt should quadruple the delay."""
delay = calculate_backoff(2, base_delay=1.0, jitter=False)
assert delay == 4.0
def test_max_delay_respected(self):
"""Delay should not exceed max_delay."""
delay = calculate_backoff(10, base_delay=1.0, max_delay=30.0, jitter=False)
assert delay == 30.0
def test_jitter_adds_randomness(self):
"""Jitter should add randomness to delay."""
delays = [calculate_backoff(1, base_delay=1.0, jitter=True) for _ in range(10)]
# With jitter, delays should vary (not all identical)
assert len(set(delays)) > 1
def test_jitter_within_bounds(self):
"""Jitter should keep delay within reasonable bounds."""
for _ in range(100):
delay = calculate_backoff(0, base_delay=2.0, jitter=True)
# Attempt 0 with base 2.0 = 2.0, with jitter should be 0.75-1.25x = 1.5-2.5
assert 1.5 <= delay <= 2.5
class TestWithRetryDecorator:
"""Tests for the @with_retry decorator."""
@pytest.mark.anyio
async def test_success_on_first_attempt(self):
"""Function should return on first successful attempt."""
call_count = 0
@with_retry(max_attempts=3)
async def successful_func():
nonlocal call_count
call_count += 1
return "success"
result = await successful_func()
assert result == "success"
assert call_count == 1
@pytest.mark.anyio
async def test_retry_on_timeout(self):
"""Should retry on timeout exception."""
call_count = 0
@with_retry(max_attempts=3, base_delay=0.01)
async def flaky_func():
nonlocal call_count
call_count += 1
if call_count < 3:
raise httpx.TimeoutException("timeout")
return "success"
result = await flaky_func()
assert result == "success"
assert call_count == 3
@pytest.mark.anyio
async def test_no_retry_on_value_error(self):
"""Should not retry on non-retryable exceptions."""
call_count = 0
@with_retry(max_attempts=3)
async def bad_func():
nonlocal call_count
call_count += 1
raise ValueError("bad value")
with pytest.raises(ValueError):
await bad_func()
assert call_count == 1
@pytest.mark.anyio
async def test_exhausted_retries(self):
"""Should raise last exception after all retries exhausted."""
call_count = 0
@with_retry(max_attempts=3, base_delay=0.01)
async def always_fails():
nonlocal call_count
call_count += 1
raise httpx.TimeoutException("always times out")
with pytest.raises(httpx.TimeoutException):
await always_fails()
assert call_count == 3
class TestRetryAsync:
"""Tests for the retry_async function."""
@pytest.mark.anyio
async def test_success_on_first_attempt(self):
"""Function should return on first successful attempt."""
async def successful_func():
return "success"
result = await retry_async(successful_func, max_attempts=3)
assert result == "success"
@pytest.mark.anyio
async def test_retry_on_connect_error(self):
"""Should retry on connection errors."""
call_count = 0
async def flaky_func():
nonlocal call_count
call_count += 1
if call_count < 2:
raise httpx.ConnectError("connection failed")
return "success"
result = await retry_async(flaky_func, max_attempts=3, base_delay=0.01)
assert result == "success"
assert call_count == 2
@pytest.mark.anyio
async def test_passes_args_and_kwargs(self):
"""Should pass arguments to the function."""
async def add(a, b, multiplier=1):
return (a + b) * multiplier
result = await retry_async(add, 2, 3, max_attempts=1, multiplier=2)
assert result == 10
@pytest.mark.anyio
async def test_no_retry_on_key_error(self):
"""Should not retry on non-retryable exceptions."""
call_count = 0
async def bad_func():
nonlocal call_count
call_count += 1
raise KeyError("missing")
with pytest.raises(KeyError):
await retry_async(bad_func, max_attempts=3)
assert call_count == 1