mirror of
https://github.com/usestrix/strix.git
synced 2026-08-16 01:16:40 +02:00
fix(llm): retry statusless mid-stream provider errors (quota/billing)
The SDK's http_status retry policy only retries errors carrying a known HTTP status code, but quota/billing (and other provider-side) failures often surface inside a streamed response as a bare error with no status code, so they were failing on the first attempt. Add a statusless retry policy to DEFAULT_MODEL_RETRY so they are retried (before any content is streamed; user aborts are never retried), restoring the pre-SDK engine's resilience. If the provider is genuinely exhausted, the error still propagates and fails the scan after retries.
This commit is contained in:
@@ -10,6 +10,7 @@ from agents.models.multi_provider import MultiProvider
|
|||||||
from agents.retry import (
|
from agents.retry import (
|
||||||
ModelRetryBackoffSettings,
|
ModelRetryBackoffSettings,
|
||||||
ModelRetrySettings,
|
ModelRetrySettings,
|
||||||
|
RetryPolicyContext,
|
||||||
retry_policies,
|
retry_policies,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,6 +21,24 @@ if TYPE_CHECKING:
|
|||||||
from strix.config.settings import Settings
|
from strix.config.settings import Settings
|
||||||
|
|
||||||
|
|
||||||
|
def _retry_statusless_provider_errors(context: RetryPolicyContext) -> bool:
|
||||||
|
"""Retry provider errors that arrive without an HTTP status code.
|
||||||
|
|
||||||
|
Quota, billing, and other provider-side failures frequently surface *inside*
|
||||||
|
a streamed response as a bare error with no ``status_code`` (the transport
|
||||||
|
already returned ``200`` before the failure). The built-in ``http_status``
|
||||||
|
policy skips these because it requires a known code, so they would otherwise
|
||||||
|
fail on the first attempt. Retrying a statusless error (the runner still
|
||||||
|
refuses to replay a stream once content has been emitted, and never retries a
|
||||||
|
user abort) mirrors the pre-SDK engine, which retried any error lacking a
|
||||||
|
definitive client status code.
|
||||||
|
"""
|
||||||
|
normalized = context.normalized
|
||||||
|
if normalized.is_abort:
|
||||||
|
return False
|
||||||
|
return normalized.status_code is None
|
||||||
|
|
||||||
|
|
||||||
class StrixProvider(MultiProvider):
|
class StrixProvider(MultiProvider):
|
||||||
"""Route any non-OpenAI prefix through LiteLLM with the prefix preserved,
|
"""Route any non-OpenAI prefix through LiteLLM with the prefix preserved,
|
||||||
so users type ``deepseek/deepseek-chat`` rather than
|
so users type ``deepseek/deepseek-chat`` rather than
|
||||||
@@ -56,6 +75,7 @@ DEFAULT_MODEL_RETRY = ModelRetrySettings(
|
|||||||
retry_policies.provider_suggested(),
|
retry_policies.provider_suggested(),
|
||||||
retry_policies.network_error(),
|
retry_policies.network_error(),
|
||||||
retry_policies.http_status((429, 500, 502, 503, 504)),
|
retry_policies.http_status((429, 500, 502, 503, 504)),
|
||||||
|
_retry_statusless_provider_errors,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""Tests for the model retry policy used by every agent model call.
|
||||||
|
|
||||||
|
The SDK's built-in ``http_status`` policy only retries errors that carry a known
|
||||||
|
HTTP status code. Quota/billing (and other provider-side) failures often surface
|
||||||
|
*inside* a streamed response as a bare error with no status code, so Strix adds a
|
||||||
|
statusless retry policy to ``DEFAULT_MODEL_RETRY`` to keep them recoverable — the
|
||||||
|
behavior the pre-SDK engine had.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from agents.retry import ModelRetryNormalizedError, RetryPolicyContext
|
||||||
|
|
||||||
|
from strix.config.models import DEFAULT_MODEL_RETRY, _retry_statusless_provider_errors
|
||||||
|
|
||||||
|
|
||||||
|
def _context(normalized: ModelRetryNormalizedError) -> RetryPolicyContext:
|
||||||
|
return RetryPolicyContext(
|
||||||
|
error=RuntimeError("boom"),
|
||||||
|
attempt=1,
|
||||||
|
max_retries=5,
|
||||||
|
stream=True,
|
||||||
|
normalized=normalized,
|
||||||
|
provider_advice=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _retries(normalized: ModelRetryNormalizedError) -> bool:
|
||||||
|
"""Evaluate the composed DEFAULT_MODEL_RETRY policy for a normalized error."""
|
||||||
|
policy = DEFAULT_MODEL_RETRY.policy
|
||||||
|
assert policy is not None
|
||||||
|
decision = asyncio.run(policy(_context(normalized)))
|
||||||
|
return bool(getattr(decision, "retry", decision))
|
||||||
|
|
||||||
|
|
||||||
|
def test_statusless_error_is_retried() -> None:
|
||||||
|
# A mid-stream quota/billing error arrives with no HTTP status code.
|
||||||
|
assert _retries(ModelRetryNormalizedError(status_code=None)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_statusless_abort_is_not_retried() -> None:
|
||||||
|
# A user/client cancellation must never be retried.
|
||||||
|
assert _retries(ModelRetryNormalizedError(status_code=None, is_abort=True)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_error_is_not_retried() -> None:
|
||||||
|
# A definitive 4xx client error (bad request/auth) is not recoverable.
|
||||||
|
assert _retries(ModelRetryNormalizedError(status_code=400)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_rate_limit_and_server_errors_are_retried() -> None:
|
||||||
|
for status in (429, 500, 502, 503, 504):
|
||||||
|
assert _retries(ModelRetryNormalizedError(status_code=status)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_policy_helper_matches_statusless_only() -> None:
|
||||||
|
assert _retry_statusless_provider_errors(_context(ModelRetryNormalizedError())) is True
|
||||||
|
assert (
|
||||||
|
_retry_statusless_provider_errors(_context(ModelRetryNormalizedError(status_code=400)))
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
_retry_statusless_provider_errors(
|
||||||
|
_context(ModelRetryNormalizedError(status_code=None, is_abort=True))
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user