feat: add retry logic for transient failures
Build and Push API / release (push) Successful in 3s
Build and Push API / build (push) Successful in 1m14s

- @with_retry decorator and retry_async() function
- Exponential backoff with jitter
- Retries on: timeout, connection errors, HTTP 429/5xx
- Web search tool now retries on network failures
- Configurable via RETRY_MAX_ATTEMPTS, RETRY_BASE_DELAY, RETRY_MAX_DELAY
- 29 new tests (205 total passing)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-11 23:22:16 +01:00
co-authored by Claude Opus 4.5
parent 2f97041aa9
commit acf231eb66
8 changed files with 509 additions and 12 deletions
+10
View File
@@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
## [0.4.2] - 2026-01-11
### Added
- Retry logic for transient failures with exponential backoff
- `src/shared/retry.py` - `@with_retry` decorator and `retry_async()` function
- Retries on: timeout, connection errors, HTTP 429/5xx
- Configurable: `RETRY_MAX_ATTEMPTS`, `RETRY_BASE_DELAY`, `RETRY_MAX_DELAY`
- Web search tool now automatically retries on network failures
- 29 retry tests (205 total tests passing)
## [0.4.1] - 2026-01-11 ## [0.4.1] - 2026-01-11
### Fixed ### Fixed
+4 -2
View File
@@ -126,7 +126,7 @@ Last updated: 2026-01-11
| **Todo tracking** | CLI | Built-in task list (`/todo`) | Medium | | **Todo tracking** | CLI | Built-in task list (`/todo`) | Medium |
| **Git integration** | CLI | Auto-commit, branch management | Medium | | **Git integration** | CLI | Auto-commit, branch management | Medium |
| **Agent handoff** | Orchestration | Explore → Plan → Task workflow | High | | **Agent handoff** | Orchestration | Explore → Plan → Task workflow | High |
| **Retry logic** | Infrastructure | Auto-retry on tool failures | Low | | ~~**Retry logic**~~ | Infrastructure | Auto-retry with exponential backoff | Low |
### Low Priority ### Low Priority
@@ -151,11 +151,12 @@ Last updated: 2026-01-11
| Task agent tests | 15 | 15 | ✅ | | Task agent tests | 15 | 15 | ✅ |
| Conversation tests | 19 | 19 | ✅ | | Conversation tests | 19 | 19 | ✅ |
| Token tests | 6 | 6 | ✅ | | Token tests | 6 | 6 | ✅ |
| Retry tests | 29 | 29 | ✅ |
| Security tests | 14 | 14 | ✅ | | Security tests | 14 | 14 | ✅ |
| Integration tests | 10 | 10 | ✅ Agent + real LLM | | Integration tests | 10 | 10 | ✅ Agent + real LLM |
| E2E tests | 12 | 12 | ✅ Full API workflow | | E2E tests | 12 | 12 | ✅ Full API workflow |
**Total: 176 tests passing** **Total: 205 tests passing**
**Test breakdown:** **Test breakdown:**
- Read/Glob/Grep tools: 17 tests - Read/Glob/Grep tools: 17 tests
@@ -168,6 +169,7 @@ Last updated: 2026-01-11
- Task agent: 15 tests - Task agent: 15 tests
- Conversations: 19 tests - Conversations: 19 tests
- Tokens: 6 tests - Tokens: 6 tests
- Retry: 29 tests
- Security: 14 tests - Security: 14 tests
- Health checks: 2 tests - Health checks: 2 tests
- Integration (LLM): 10 tests - Integration (LLM): 10 tests
+3
View File
@@ -217,6 +217,9 @@ All settings via environment variables or `.env`:
| SUMMARIZATION_THRESHOLD | 0.8 | Summarize at N% of max tokens | | SUMMARIZATION_THRESHOLD | 0.8 | Summarize at N% of max tokens |
| SUMMARIZATION_TARGET_TOKENS | 500 | Target summary size | | SUMMARIZATION_TARGET_TOKENS | 500 | Target summary size |
| KEEP_RECENT_MESSAGES | 6 | Messages to keep unsummarized | | KEEP_RECENT_MESSAGES | 6 | Messages to keep unsummarized |
| RETRY_MAX_ATTEMPTS | 3 | Max retry attempts for transient failures |
| RETRY_BASE_DELAY | 1.0 | Base delay between retries (seconds) |
| RETRY_MAX_DELAY | 30.0 | Maximum delay between retries (seconds) |
--- ---
+1 -1
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "webber-api" name = "webber-api"
version = "0.4.1" version = "0.4.2"
description = "Webber API - Multi-Agent AI Development Server" description = "Webber API - Multi-Agent AI Development Server"
authors = [ authors = [
{name = "jpmschweitzer"} {name = "jpmschweitzer"}
+27 -9
View File
@@ -9,6 +9,7 @@ import httpx
from src.domains.tools.base import BaseTool, ToolResult from src.domains.tools.base import BaseTool, ToolResult
from src.shared.config import get_settings from src.shared.config import get_settings
from src.shared.logging import logged, get_logger from src.shared.logging import logged, get_logger
from src.shared.retry import retry_async
logger = get_logger(__name__) logger = get_logger(__name__)
@@ -73,6 +74,24 @@ IMPORTANT:
self.searxng_url = (searxng_url or settings.searxng_url).rstrip("/") self.searxng_url = (searxng_url or settings.searxng_url).rstrip("/")
self.timeout = timeout or settings.searxng_timeout self.timeout = timeout or settings.searxng_timeout
self.max_results = max_results self.max_results = max_results
# Retry settings
self.retry_max_attempts = settings.retry_max_attempts
self.retry_base_delay = settings.retry_base_delay
self.retry_max_delay = settings.retry_max_delay
async def _fetch_search_results(self, params: dict) -> dict:
"""
Fetch search results from SearXNG.
This method is wrapped with retry logic for transient failures.
"""
async with httpx.AsyncClient(timeout=self.timeout) as client:
response = await client.get(
f"{self.searxng_url}/search",
params=params,
)
response.raise_for_status()
return response.json()
@logged() @logged()
async def execute( async def execute(
@@ -111,16 +130,15 @@ IMPORTANT:
params["categories"] = categories params["categories"] = categories
try: try:
async with httpx.AsyncClient(timeout=self.timeout) as client: data = await retry_async(
response = await client.get( self._fetch_search_results,
f"{self.searxng_url}/search", params,
params=params, max_attempts=self.retry_max_attempts,
) base_delay=self.retry_base_delay,
response.raise_for_status() max_delay=self.retry_max_delay,
data = response.json() )
except httpx.TimeoutException: except httpx.TimeoutException:
return self._error(f"Search timed out after {self.timeout}s") return self._error(f"Search timed out after {self.timeout}s (all retries exhausted)")
except httpx.HTTPStatusError as e: except httpx.HTTPStatusError as e:
return self._error(f"Search failed: HTTP {e.response.status_code}") return self._error(f"Search failed: HTTP {e.response.status_code}")
except httpx.RequestError as e: except httpx.RequestError as e:
+5
View File
@@ -90,6 +90,11 @@ class Settings(BaseSettings):
summarization_target_tokens: int = 500 # Target summary size summarization_target_tokens: int = 500 # Target summary size
keep_recent_messages: int = 6 # Messages to keep unsummarized (3 turns) keep_recent_messages: int = 6 # Messages to keep unsummarized (3 turns)
# Retry logic
retry_max_attempts: int = 3 # Max retry attempts for transient failures
retry_base_delay: float = 1.0 # Base delay in seconds
retry_max_delay: float = 30.0 # Maximum delay in seconds
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
case_sensitive=False, case_sensitive=False,
+215
View File
@@ -0,0 +1,215 @@
"""
Retry utilities for handling transient failures.
Provides decorators and helpers for automatic retry with exponential backoff.
"""
import asyncio
import random
from collections.abc import Awaitable, Callable
from functools import wraps
from typing import Any, TypeVar
import httpx
from src.shared.logging import get_logger
logger = get_logger(__name__)
T = TypeVar("T")
# Exceptions that should trigger a retry
RETRYABLE_EXCEPTIONS = (
httpx.TimeoutException,
httpx.ConnectError,
httpx.ReadError,
httpx.WriteError,
httpx.ConnectTimeout,
httpx.ReadTimeout,
httpx.WriteTimeout,
httpx.PoolTimeout,
ConnectionError,
TimeoutError,
OSError, # Covers many network-related errors
)
def is_retryable_http_status(status_code: int) -> bool:
"""
Check if an HTTP status code should trigger a retry.
Retryable:
- 429 Too Many Requests (rate limited)
- 500 Internal Server Error
- 502 Bad Gateway
- 503 Service Unavailable
- 504 Gateway Timeout
"""
return status_code in (429, 500, 502, 503, 504)
def is_retryable_exception(exc: Exception) -> bool:
"""Check if an exception should trigger a retry."""
if isinstance(exc, RETRYABLE_EXCEPTIONS):
return True
# Check for retryable HTTP status codes
if isinstance(exc, httpx.HTTPStatusError):
return is_retryable_http_status(exc.response.status_code)
return False
def calculate_backoff(
attempt: int,
base_delay: float = 1.0,
max_delay: float = 60.0,
jitter: bool = True,
) -> float:
"""
Calculate exponential backoff delay with optional jitter.
Args:
attempt: Current attempt number (0-indexed)
base_delay: Base delay in seconds
max_delay: Maximum delay in seconds
jitter: Add random jitter to prevent thundering herd
Returns:
Delay in seconds
"""
# Exponential backoff: base_delay * 2^attempt
delay = min(base_delay * (2 ** attempt), max_delay)
if jitter:
# Add up to 25% random jitter
delay = delay * (0.75 + random.random() * 0.5)
return delay
def with_retry(
max_attempts: int = 3,
base_delay: float = 1.0,
max_delay: float = 60.0,
retryable_exceptions: tuple[type[Exception], ...] | None = None,
) -> Callable[[Callable[..., Awaitable[T]]], Callable[..., Awaitable[T]]]:
"""
Decorator for async functions that should retry on transient failures.
Args:
max_attempts: Maximum number of attempts (including initial)
base_delay: Base delay between retries in seconds
max_delay: Maximum delay between retries in seconds
retryable_exceptions: Additional exceptions to retry on
Returns:
Decorated function with retry logic
Example:
@with_retry(max_attempts=3, base_delay=1.0)
async def fetch_data():
async with httpx.AsyncClient() as client:
response = await client.get(url)
return response.json()
"""
extra_exceptions = retryable_exceptions or ()
def decorator(func: Callable[..., Awaitable[T]]) -> Callable[..., Awaitable[T]]:
@wraps(func)
async def wrapper(*args: Any, **kwargs: Any) -> T:
last_exception: Exception | None = None
for attempt in range(max_attempts):
try:
return await func(*args, **kwargs)
except (*RETRYABLE_EXCEPTIONS, *extra_exceptions) as e:
last_exception = e
should_retry = True
except httpx.HTTPStatusError as e:
last_exception = e
should_retry = is_retryable_http_status(e.response.status_code)
except Exception:
# Non-retryable exception, re-raise immediately
raise
if should_retry and attempt < max_attempts - 1:
delay = calculate_backoff(attempt, base_delay, max_delay)
logger.warning(
f"Retry {attempt + 1}/{max_attempts - 1} for {func.__name__} "
f"after {delay:.2f}s due to: {last_exception}"
)
await asyncio.sleep(delay)
elif not should_retry:
# Non-retryable HTTP error
raise last_exception # type: ignore
# All retries exhausted
logger.error(
f"All {max_attempts} attempts failed for {func.__name__}: {last_exception}"
)
raise last_exception # type: ignore
return wrapper
return decorator
async def retry_async(
func: Callable[..., Awaitable[T]],
*args: Any,
max_attempts: int = 3,
base_delay: float = 1.0,
max_delay: float = 60.0,
**kwargs: Any,
) -> T:
"""
Retry an async function with exponential backoff.
Alternative to decorator when you need per-call control.
Args:
func: Async function to call
*args: Positional arguments for func
max_attempts: Maximum number of attempts
base_delay: Base delay between retries
max_delay: Maximum delay between retries
**kwargs: Keyword arguments for func
Returns:
Result of func
Raises:
Last exception if all retries fail
Example:
result = await retry_async(
fetch_data,
url,
max_attempts=5,
timeout=30,
)
"""
last_exception: Exception | None = None
for attempt in range(max_attempts):
try:
return await func(*args, **kwargs)
except Exception as e:
last_exception = e
if not is_retryable_exception(e):
raise
if attempt < max_attempts - 1:
delay = calculate_backoff(attempt, base_delay, max_delay)
logger.warning(
f"Retry {attempt + 1}/{max_attempts - 1} "
f"after {delay:.2f}s due to: {e}"
)
await asyncio.sleep(delay)
raise last_exception # type: ignore
+244
View File
@@ -0,0 +1,244 @@
"""
Tests for retry utilities.
"""
import pytest
from unittest.mock import AsyncMock, patch
import httpx
from src.shared.retry import (
with_retry,
retry_async,
is_retryable_exception,
is_retryable_http_status,
calculate_backoff,
)
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