Merge pull request #2517 from arc53/better-retry

Better retry
This commit is contained in:
Alex authored and GitHub committed 2026-05-31 17:01:07 +01:00
commit bcb763227c
3 files changed
+327 -89

No files matched your search

+79 -48
View File
@@ -136,32 +136,22 @@ class BaseLLM(ABC):
return args_dict
return {k: v for k, v in args_dict.items() if v is not None}
@staticmethod
def _is_non_retriable_client_error(exc: BaseException) -> bool:
"""4xx errors mean the request itself is malformed — retrying with
a different model fails identically and doubles the work. Only
transient/5xx/connection errors should trigger fallback."""
try:
from google.genai.errors import ClientError as _GenaiClientError
if isinstance(exc, _GenaiClientError):
return True
except ImportError:
pass
for attr in ("status_code", "code", "http_status"):
v = getattr(exc, attr, None)
if isinstance(v, int) and 400 <= v < 500:
return True
resp = getattr(exc, "response", None)
v = getattr(resp, "status_code", None)
return isinstance(v, int) and 400 <= v < 500
def _execute_with_fallback(
self, method_name: str, decorators: list, *args, **kwargs
):
"""
Execute method with fallback support.
Any error raised by the primary model triggers a single attempt on
the fallback model, when one is configured. There is no error
classification: 5xx/transient failures are obviously recoverable on a
different model, and for client-side (4xx) errors — including rate
limits (429) and provider-specific payload rejections — the one extra
attempt is cheap insurance, since a second provider often accepts what
the first refused. ``GeneratorExit``/cancellation are ``BaseException``
subclasses and so bypass this handler (no fallback on client
disconnect), which is intentional.
Args:
method_name: Name of the raw method ('_raw_gen' or '_raw_gen_stream')
decorators: List of decorators to apply
@@ -185,12 +175,6 @@ class BaseLLM(ABC):
try:
return decorated_method()
except Exception as e:
if self._is_non_retriable_client_error(e):
logger.error(
f"Primary LLM failed with non-retriable client error; "
f"skipping fallback: {str(e)}"
)
raise
if not self.fallback_llm:
logger.error(f"Primary LLM failed and no fallback configured: {str(e)}")
raise
@@ -200,6 +184,18 @@ class BaseLLM(ABC):
f"{fallback.model_id}. Error: {str(e)}"
)
# Mirror the streaming path: emit the fallback's own start event so
# dashboards attribute the response to the backup provider, not the
# failed primary.
fallback._emit_gen_start_log(
fallback.model_id,
kwargs.get("messages"),
kwargs.get("tools"),
bool(
kwargs.get("_usage_attachments")
or kwargs.get("attachments")
),
)
# Apply decorators to fallback's raw method directly — calling
# fallback.gen() would re-enter the orchestrator and recurse via
# fallback.fallback_llm.
@@ -210,13 +206,7 @@ class BaseLLM(ABC):
try:
return fallback_method(fallback, *args, **fallback_kwargs)
except Exception as e2:
if self._is_non_retriable_client_error(e2):
logger.error(
f"Fallback LLM failed with non-retriable client "
f"error; giving up: {str(e2)}"
)
else:
logger.error(f"Fallback LLM also failed; giving up: {str(e2)}")
logger.error(f"Fallback LLM also failed; giving up: {str(e2)}")
raise
def _stream_with_fallback(
@@ -233,12 +223,6 @@ class BaseLLM(ABC):
try:
yield from decorated_method()
except Exception as e:
if self._is_non_retriable_client_error(e):
logger.error(
f"Primary LLM failed mid-stream with non-retriable client "
f"error; skipping fallback: {str(e)}"
)
raise
if not self.fallback_llm:
logger.error(
f"Primary LLM failed and no fallback configured: {str(e)}"
@@ -270,18 +254,18 @@ class BaseLLM(ABC):
try:
yield from fallback_method(fallback, *args, **fallback_kwargs)
except Exception as e2:
if self._is_non_retriable_client_error(e2):
logger.error(
f"Fallback LLM failed mid-stream with non-retriable "
f"client error; giving up: {str(e2)}"
)
else:
logger.error(
f"Fallback LLM also failed mid-stream; giving up: {str(e2)}"
)
logger.error(
f"Fallback LLM also failed mid-stream; giving up: {str(e2)}"
)
raise
def gen(self, model, messages, stream=False, tools=None, *args, **kwargs):
# Mirror gen_stream: emit the start event before the decorators run so
# ``_usage_attachments`` is still in kwargs (the gen decorators pop it).
has_attachments = bool(
kwargs.get("_usage_attachments") or kwargs.get("attachments")
)
self._emit_gen_start_log(model, messages, tools, has_attachments)
decorators = [gen_token_usage, gen_cache]
return self._execute_with_fallback(
"_raw_gen",
@@ -294,6 +278,53 @@ class BaseLLM(ABC):
**kwargs,
)
def _emit_gen_start_log(self, model, messages, tools, has_attachments):
# Non-streaming counterpart to ``_emit_stream_start_log``. Emitted by
# ``gen()`` before the call — and again for the fallback provider in
# ``_execute_with_fallback`` — so non-streaming invocations are
# observable from the first log line, not just streaming ones. A
# distinct event name keeps non-stream calls out of stream dashboards.
logging.info(
"llm_gen_start",
extra={
"model": model,
"provider": self.provider_name,
"message_count": len(messages) if messages is not None else 0,
"has_attachments": bool(has_attachments),
"has_tools": bool(tools),
},
)
def _emit_gen_finished_log(
self,
model,
*,
prompt_tokens,
completion_tokens,
latency_ms,
cached_tokens=None,
error=None,
):
# Non-streaming counterpart to ``_emit_stream_finished_log``. Paired
# with ``llm_gen_start`` so cost dashboards can join start/finish for
# non-streaming calls just as they do for streams. Token counts are
# client-side estimates from ``gen_token_usage``; ``status`` is
# ``"error"`` when the call raised. A distinct event name keeps
# non-stream calls out of stream dashboards.
extra = {
"model": model,
"provider": self.provider_name,
"prompt_tokens": int(prompt_tokens),
"completion_tokens": int(completion_tokens),
"latency_ms": int(latency_ms),
"status": "error" if error is not None else "ok",
}
if cached_tokens is not None:
extra["cached_tokens"] = int(cached_tokens)
if error is not None:
extra["error_class"] = type(error).__name__
logging.info("llm_gen_finished", extra=extra)
def _emit_stream_start_log(self, model, messages, tools, has_attachments):
# Stamped with ``self.provider_name`` so dashboards can group calls
# by vendor; the fallback path emits its own copy on the fallback
+30 -6
View File
@@ -147,6 +147,11 @@ def gen_token_usage(func):
paths that introspect it (e.g., logging, response payloads). DB
persistence happens here for every call so primary streams,
side-channel LLMs, and no-save flows all produce rows uniformly.
Mirrors ``stream_token_usage``: persistence and the
``llm_gen_finished`` log fire from a ``finally`` block, so a failed
call still records the prompt tokens it consumed and emits a
``status="error"`` finish event.
"""
def wrapper(self, model, messages, stream, tools, **kwargs):
usage_attachments = kwargs.pop("_usage_attachments", None)
@@ -157,12 +162,31 @@ def gen_token_usage(func):
usage_attachments=usage_attachments,
**kwargs,
)
result = func(self, model, messages, stream, tools, **kwargs)
call_usage["generated_tokens"] += _count_tokens(result)
self.token_usage["prompt_tokens"] += call_usage["prompt_tokens"]
self.token_usage["generated_tokens"] += call_usage["generated_tokens"]
_persist_call_usage(self, call_usage)
return result
started_at = time.monotonic()
error: BaseException | None = None
try:
result = func(self, model, messages, stream, tools, **kwargs)
call_usage["generated_tokens"] += _count_tokens(result)
return result
except Exception as exc:
error = exc
raise
finally:
self.token_usage["prompt_tokens"] += call_usage["prompt_tokens"]
self.token_usage["generated_tokens"] += call_usage["generated_tokens"]
_persist_call_usage(self, call_usage)
emit = getattr(self, "_emit_gen_finished_log", None)
if callable(emit):
try:
emit(
model,
prompt_tokens=call_usage["prompt_tokens"],
completion_tokens=call_usage["generated_tokens"],
latency_ms=int((time.monotonic() - started_at) * 1000),
error=error,
)
except Exception:
logger.exception("Failed to emit llm_gen_finished")
return wrapper
+218 -35
View File
@@ -81,6 +81,160 @@ class TestGenMethods:
result = llm.gen(model="m", messages=[{"role": "user", "content": "hi"}])
assert result == "hello"
@patch("application.llm.base.gen_cache", lambda f: f)
@patch("application.llm.base.gen_token_usage", lambda f: f)
def test_gen_emits_llm_gen_start_event(self, caplog):
# Non-streaming counterpart to the llm_stream_start event: gen() must
# log before the model is queried so every call is observable.
import logging as _logging
class FakeProvider(StubLLM):
provider_name = "fake-provider"
llm = FakeProvider(raw_gen_return="hi")
with caplog.at_level(_logging.INFO, logger="root"):
llm.gen(
model="m1",
messages=[
{"role": "user", "content": "hi"},
{"role": "assistant", "content": "hey"},
],
tools=[{"name": "t"}],
_usage_attachments=[{"path": "/tmp/a.png"}],
)
starts = [r for r in caplog.records if r.message == "llm_gen_start"]
assert len(starts) == 1
evt = starts[0]
assert evt.model == "m1"
assert evt.provider == "fake-provider"
assert evt.message_count == 2
assert evt.has_attachments is True
assert evt.has_tools is True
@patch("application.llm.base.gen_cache", lambda f: f)
@patch("application.llm.base.gen_token_usage", lambda f: f)
def test_gen_emits_event_without_attachments_or_tools(self, caplog):
import logging as _logging
llm = StubLLM(raw_gen_return="hi")
with caplog.at_level(_logging.INFO, logger="root"):
llm.gen(model="m1", messages=[])
evt = next(r for r in caplog.records if r.message == "llm_gen_start")
assert evt.message_count == 0
assert evt.has_attachments is False
assert evt.has_tools is False
# BaseLLM default — concrete providers always override.
assert evt.provider == "unknown"
@patch("application.llm.base.gen_cache", lambda f: f)
@patch("application.llm.base.gen_token_usage", lambda f: f)
def test_gen_fallback_emits_gen_start_for_fallback_provider(self, caplog):
# The fallback raw path bypasses gen(), so _execute_with_fallback must
# emit a second llm_gen_start tagged with the backup vendor/model —
# otherwise dashboards record only the failed primary.
import logging as _logging
class PrimaryProvider(FailingLLM):
provider_name = "primary-vendor"
class FallbackProvider(FallbackLLM):
provider_name = "fallback-vendor"
primary = PrimaryProvider()
primary._fallback_llm = FallbackProvider(model_id="backup-model-id")
with caplog.at_level(_logging.INFO, logger="root"):
result = primary.gen(
model="primary-model",
messages=[{"role": "user", "content": "hi"}],
)
assert result == "fallback_result"
starts = [r for r in caplog.records if r.message == "llm_gen_start"]
assert len(starts) == 2
assert starts[0].provider == "primary-vendor"
assert starts[0].model == "primary-model"
assert starts[1].provider == "fallback-vendor"
assert starts[1].model == "backup-model-id"
@patch("application.llm.base.gen_cache", lambda f: f)
def test_gen_emits_llm_gen_finished_on_success(self, caplog):
# Real gen_token_usage (only gen_cache patched) so the emit-from-
# finally path runs. user_api_key=None makes _persist_call_usage
# short-circuit before any DB access — same trick as the stream test.
import logging as _logging
class FakeProvider(StubLLM):
provider_name = "fake-provider"
llm = FakeProvider(raw_gen_return="alpha beta")
llm.user_api_key = None
with caplog.at_level(_logging.INFO, logger="root"):
llm.gen(model="m1", messages=[{"role": "user", "content": "hi"}])
finished = [r for r in caplog.records if r.message == "llm_gen_finished"]
assert len(finished) == 1
evt = finished[0]
assert evt.model == "m1"
assert evt.provider == "fake-provider"
assert evt.status == "ok"
assert isinstance(evt.prompt_tokens, int) and evt.prompt_tokens >= 0
assert isinstance(evt.completion_tokens, int) and evt.completion_tokens > 0
assert isinstance(evt.latency_ms, int) and evt.latency_ms >= 0
# cached_tokens / error_class are intentionally absent on success.
assert not hasattr(evt, "cached_tokens")
assert not hasattr(evt, "error_class")
@patch("application.llm.base.gen_cache", lambda f: f)
def test_gen_emits_llm_gen_finished_on_error(self, caplog):
import logging as _logging
class FakeProvider(BaseLLM):
provider_name = "fake-provider"
def _raw_gen(self, baseself, model, messages, stream=False, tools=None, **kw):
raise RuntimeError("gen_boom")
def _raw_gen_stream(self, baseself, model, messages, stream=True, tools=None, **kw):
yield "x"
llm = FakeProvider()
llm.user_api_key = None
with caplog.at_level(_logging.INFO, logger="root"), pytest.raises(RuntimeError):
llm.gen(model="m1", messages=[{"role": "user", "content": "hi"}])
finished = [r for r in caplog.records if r.message == "llm_gen_finished"]
assert len(finished) == 1
evt = finished[0]
assert evt.status == "error"
assert evt.error_class == "RuntimeError"
# Prompt tokens are still recorded — the request was sent and billed.
assert evt.prompt_tokens > 0
@patch("application.llm.base.gen_cache", lambda f: f)
def test_gen_finished_event_paired_with_gen_start(self, caplog):
# The two events form a pair the cost dashboards join on; verify they
# come in order and from the same provider/model.
import logging as _logging
class FakeProvider(StubLLM):
provider_name = "fake-provider"
llm = FakeProvider(raw_gen_return="x")
llm.user_api_key = None
with caplog.at_level(_logging.INFO, logger="root"):
llm.gen(model="m1", messages=[])
records = [
r for r in caplog.records
if r.message in ("llm_gen_start", "llm_gen_finished")
]
assert [r.message for r in records] == ["llm_gen_start", "llm_gen_finished"]
assert records[0].model == records[1].model == "m1"
assert records[0].provider == records[1].provider == "fake-provider"
@patch("application.llm.base.stream_cache", lambda f: f)
@patch("application.llm.base.stream_token_usage", lambda f: f)
def test_gen_stream_yields_results(self):
@@ -367,54 +521,39 @@ class _ClientErrorLLM(BaseLLM):
@pytest.mark.unit
class TestNonRetriableClientError:
class TestClientErrorFallback:
"""Any primary error triggers a single fallback attempt.
def test_helper_detects_4xx_status_code(self):
assert BaseLLM._is_non_retriable_client_error(_StatusError(400))
assert BaseLLM._is_non_retriable_client_error(_StatusError(404))
assert BaseLLM._is_non_retriable_client_error(_StatusError(429))
def test_helper_passes_5xx_through(self):
# 5xx and connection errors should still trigger fallback.
assert not BaseLLM._is_non_retriable_client_error(_StatusError(500))
assert not BaseLLM._is_non_retriable_client_error(_StatusError(503))
assert not BaseLLM._is_non_retriable_client_error(RuntimeError("oops"))
def test_helper_detects_genai_client_error(self):
try:
from google.genai.errors import ClientError
except ImportError:
pytest.skip("google-genai not installed")
# ClientError(code, response_json, response=None)
exc = ClientError(400, {"error": {"message": "bad", "code": 400}}, None)
assert BaseLLM._is_non_retriable_client_error(exc)
def test_helper_detects_response_status_code(self):
exc = RuntimeError("wrapped")
exc.response = type("R", (), {"status_code": 401})()
assert BaseLLM._is_non_retriable_client_error(exc)
There is no error-class gate: 4xx (including 429 rate limits and
provider-specific payload rejections) and Gemini ``ClientError`` now
fall back just like 5xx/transient failures. Client disconnect
(``GeneratorExit``) is a ``BaseException`` and still bypasses fallback.
"""
# 4xx that used to be force-skipped; each should now reach the fallback.
@pytest.mark.parametrize("status_code", [400, 401, 403, 404, 413, 422, 429])
@patch("application.llm.base.gen_cache", lambda f: f)
@patch("application.llm.base.gen_token_usage", lambda f: f)
def test_4xx_skips_fallback(self):
def test_4xx_now_falls_back(self, status_code):
fallback = FallbackLLM(model_id="fallback-model")
llm = _ClientErrorLLM(status_code=400)
llm = _ClientErrorLLM(status_code=status_code)
llm._fallback_llm = fallback
with pytest.raises(_StatusError):
llm.gen(model="m", messages=[])
assert not fallback.gen_called
result = llm.gen(model="m", messages=[])
assert result == "fallback_result"
assert fallback.gen_called
@pytest.mark.parametrize("status_code", [400, 429])
@patch("application.llm.base.stream_cache", lambda f: f)
@patch("application.llm.base.stream_token_usage", lambda f: f)
def test_4xx_skips_stream_fallback(self):
def test_4xx_now_falls_back_stream(self, status_code):
fallback = FallbackLLM(model_id="fallback-model")
llm = _ClientErrorLLM(status_code=400)
llm = _ClientErrorLLM(status_code=status_code)
llm._fallback_llm = fallback
with pytest.raises(_StatusError):
list(llm.gen_stream(model="m", messages=[]))
assert not fallback.gen_stream_called
result = list(llm.gen_stream(model="m", messages=[]))
assert "fallback_chunk" in result
assert fallback.gen_stream_called
@patch("application.llm.base.gen_cache", lambda f: f)
@patch("application.llm.base.gen_token_usage", lambda f: f)
@@ -427,6 +566,50 @@ class TestNonRetriableClientError:
assert result == "fallback_result"
assert fallback.gen_called
@patch("application.llm.base.gen_cache", lambda f: f)
@patch("application.llm.base.gen_token_usage", lambda f: f)
def test_genai_client_error_now_falls_back(self):
"""A Gemini ClientError (4xx) is no longer force-skipped."""
try:
from google.genai.errors import ClientError
except ImportError:
pytest.skip("google-genai not installed")
class _GenaiErrorLLM(BaseLLM):
def _raw_gen(self, baseself, model, messages, stream=False, tools=None, **kw):
raise ClientError(400, {"error": {"message": "bad", "code": 400}}, None)
def _raw_gen_stream(self, baseself, model, messages, stream=True, tools=None, **kw):
raise ClientError(400, {"error": {"message": "bad", "code": 400}}, None)
yield # unreachable; makes this a generator
fallback = FallbackLLM(model_id="fallback-model")
llm = _GenaiErrorLLM()
llm._fallback_llm = fallback
result = llm.gen(model="m", messages=[])
assert result == "fallback_result"
assert fallback.gen_called
@patch("application.llm.base.gen_cache", lambda f: f)
@patch("application.llm.base.gen_token_usage", lambda f: f)
def test_fallback_failure_propagates(self):
"""When the fallback also fails, its error propagates to the caller."""
class _FailingFallback(BaseLLM):
def _raw_gen(self, baseself, model, messages, stream=False, tools=None, **kw):
raise _StatusError(400, "fallback_failed")
def _raw_gen_stream(self, baseself, model, messages, stream=True, tools=None, **kw):
raise _StatusError(400, "fallback_failed")
yield # unreachable; makes this a generator
llm = _ClientErrorLLM(status_code=429)
llm._fallback_llm = _FailingFallback(model_id="fb")
with pytest.raises(_StatusError, match="fallback_failed"):
llm.gen(model="m", messages=[])
# ---------------------------------------------------------------------------
# fallback_llm property: backup model resolution