fix(providers): apply fallback policy to raised errors

This commit is contained in:
KDB
2026-09-03 18:31:35 +08:00
committed by Xubin Ren
parent 0ec6aee621
commit 18d1a325b6
2 changed files with 257 additions and 3 deletions
+71 -3
View File
@@ -4,11 +4,13 @@
from __future__ import annotations
import asyncio
import time
from collections.abc import Awaitable, Callable
from dataclasses import replace
from typing import Any
import httpx
from loguru import logger
from nanobot.providers.base import (
@@ -59,6 +61,7 @@ _AUTHENTICATION_ERROR_TOKENS = (
"access_denied",
"account_deactivated",
"organization_deactivated",
"not logged in",
)
_NON_FALLBACK_ERROR_KINDS = frozenset({
"content_filter",
@@ -428,7 +431,14 @@ class FallbackProvider(LLMProvider):
if self._primary_available():
primary_was_attempted = True
response = await call(self._primary, kwargs)
response, primary_exception = await self._call_provider(
call, self._primary, kwargs
)
if primary_exception is not None:
logger.warning(
"Primary model '{}' raised {} before responding",
primary_model, type(primary_exception).__name__,
)
if response.finish_reason != "error":
self._primary_failures = 0
self._primary_tripped_at = None
@@ -457,7 +467,7 @@ class FallbackProvider(LLMProvider):
if not self._should_fallback(response):
logger.warning(
"Primary model '{}' returned non-fallbackable error: {}",
"Primary model '{}' failed with non-fallbackable error: {}",
primary_model,
(response.content or "")[:120],
)
@@ -544,7 +554,14 @@ class FallbackProvider(LLMProvider):
fallback_kwargs.pop("reasoning_effort", None)
else:
fallback_kwargs["reasoning_effort"] = fallback.reasoning_effort
fallback_response = await call(fallback_provider, fallback_kwargs)
fallback_response, fallback_exception = await self._call_provider(
call, fallback_provider, fallback_kwargs
)
if fallback_exception is not None:
logger.warning(
"Fallback '{}' raised {}",
fallback_model, type(fallback_exception).__name__,
)
if fallback_response.finish_reason != "error":
# Do not publish a model switch merely because a fallback was
@@ -593,6 +610,57 @@ class FallbackProvider(LLMProvider):
error_should_retry=True,
)
@staticmethod
async def _call_provider(
call: Callable[[LLMProvider, dict[str, Any]], Awaitable[LLMResponse]],
provider: LLMProvider,
kwargs: dict[str, Any],
) -> tuple[LLMResponse, Exception | None]:
"""Turn provider exceptions into error responses without swallowing cancellation."""
try:
return await call(provider, kwargs), None
except asyncio.CancelledError:
raise
except Exception as exc:
error_name = type(exc).__name__.lower()
error_kind: str | None = None
error_should_retry: bool | None = None
if isinstance(exc, (httpx.TimeoutException, asyncio.TimeoutError)):
error_kind = "timeout"
error_should_retry = True
elif isinstance(exc, (httpx.NetworkError, httpx.TransportError, ConnectionError)):
error_kind = "connection"
error_should_retry = True
elif any(
token in error_name
for token in ("auth", "credential", "permissiondenied", "unauthor")
):
error_kind = "authentication"
elif "ratelimit" in error_name or "throttl" in error_name:
error_kind = "rate_limit"
error_should_retry = True
elif "server" in error_name or "internal" in error_name:
error_kind = "server_error"
error_should_retry = True
response = getattr(exc, "response", None)
raw_status = getattr(exc, "status_code", None)
if raw_status is None and response is not None:
raw_status = getattr(response, "status_code", None)
try:
error_status_code = int(raw_status) if raw_status is not None else None
except (TypeError, ValueError):
error_status_code = None
detail = str(exc).strip() or type(exc).__name__
return LLMResponse(
content=f"Error calling LLM: {detail}",
finish_reason="error",
error_status_code=error_status_code,
error_kind=error_kind,
error_should_retry=error_should_retry,
), exc
async def _notify_fallback_model(self, model: str) -> None:
if self._fallback_model_observer is None:
return