Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions sources/core/llm_provider.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ def __init__(
self.agent_name = agent_name
self.memory_path = memory_path
self.use_flat_cache = use_flat_cache
# Hard cap on transient-error retries in __call__. The retry loop is
# otherwise unbounded (`while True`), so a persistently overloaded or
# rate-limited provider would retry forever, re-sending the full prompt
# each time. Both retryable paths raise once this ceiling is reached.
self.max_retries = 3
self.logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -479,7 +483,11 @@ def __call__(self, prompt: str, timeout: int = 180, use_cache: bool = True) -> s
break

except TimeoutError as e:
# Timeout is retryable
# Timeout is retryable, up to the max_retries ceiling.
if attempt >= self.max_retries:
raise RuntimeError(
f"❌ LLM API error: timed out after {self.max_retries} retries"
) from e
wait_time = self._calculate_backoff_wait(attempt, max_wait)
self.logger.warning(
f"⌛ Timeout on attempt {attempt + 1}. Retrying in {wait_time:.1f}s..."
Expand Down Expand Up @@ -517,7 +525,13 @@ def __call__(self, prompt: str, timeout: int = 180, use_cache: bool = True) -> s
)
attempt += 1
else:
# Regular retry with backoff for other retryable errors
# Regular retry with backoff for other retryable errors,
# bounded by the max_retries ceiling so a persistently
# failing provider cannot loop forever.
if attempt >= self.max_retries:
raise RuntimeError(
f"❌ LLM API error after {self.max_retries} retries: {str(e)}"
) from e
wait_time = self._calculate_backoff_wait(attempt, max_wait)
self.logger.warning(
f"⚠️ Retryable error on attempt {attempt + 1}: {str(e)[:512]}. "
Expand Down
69 changes: 69 additions & 0 deletions tests/llm_provider_test.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""Tests for the LLMProvider transient-error retry loop.

Focus: the retry loop in ``LLMProvider.__call__`` is bounded by
``self.max_retries`` on both retryable paths (timeout and generic retryable
errors) and can no longer loop forever on a persistently failing provider.
"""

import sys
from pathlib import Path
from unittest.mock import patch

import pytest

sys.path.append(str(Path(__file__).parent.parent))

from sources.core.llm_provider import LLMConfig, LLMProvider


def _make_provider() -> LLMProvider:
"""Build a provider with no network dependencies and no on-disk cache."""
return LLMProvider(agent_name="test", config=LLMConfig(model="anthropic/claude-sonnet-4-5"))


def test_retry_loop_raises_after_max_retries():
"""A persistently retryable error terminates instead of looping forever."""
provider = _make_provider()
retryable = Exception("provider overloaded, please retry")

with patch("litellm.completion", side_effect=retryable) as completion, patch(
"sources.core.llm_provider.time.sleep"
):
with pytest.raises(RuntimeError, match="retries"):
provider("hello", use_cache=False)

# attempts 0..max_retries are tried, then the ceiling raises.
assert completion.call_count == provider.max_retries + 1


def test_timeout_retry_is_bounded():
"""Repeated timeouts terminate at the max_retries ceiling."""
provider = _make_provider()

with patch("litellm.completion", side_effect=TimeoutError("slow")) as completion, patch(
"sources.core.llm_provider.time.sleep"
):
with pytest.raises(RuntimeError, match="timed out"):
provider("hello", use_cache=False)

assert completion.call_count == provider.max_retries + 1


def test_non_retryable_error_raises_immediately():
"""A non-retryable error is raised on the first attempt, with no retry."""
provider = _make_provider()

with patch("litellm.completion", side_effect=ValueError("bad request")) as completion, patch(
"sources.core.llm_provider.time.sleep"
):
with pytest.raises(RuntimeError):
provider("hello", use_cache=False)

assert completion.call_count == 1


if __name__ == "__main__":
test_retry_loop_raises_after_max_retries()
test_timeout_retry_is_bounded()
test_non_retryable_error_raises_immediately()
print("llm_provider retry tests passed")