From b9c2592b5322019344bcde26d6f9bf1fb2031b3b Mon Sep 17 00:00:00 2001 From: Ahmed Allam Date: Fri, 17 Jul 2026 23:35:36 +0000 Subject: [PATCH] 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. --- strix/config/models.py | 20 ++++++++++++ tests/test_model_retry.py | 69 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 tests/test_model_retry.py diff --git a/strix/config/models.py b/strix/config/models.py index 213bc5ff..db9fcb7b 100644 --- a/strix/config/models.py +++ b/strix/config/models.py @@ -10,6 +10,7 @@ from agents.models.multi_provider import MultiProvider from agents.retry import ( ModelRetryBackoffSettings, ModelRetrySettings, + RetryPolicyContext, retry_policies, ) @@ -20,6 +21,24 @@ if TYPE_CHECKING: 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): """Route any non-OpenAI prefix through LiteLLM with the prefix preserved, so users type ``deepseek/deepseek-chat`` rather than @@ -56,6 +75,7 @@ DEFAULT_MODEL_RETRY = ModelRetrySettings( retry_policies.provider_suggested(), retry_policies.network_error(), retry_policies.http_status((429, 500, 502, 503, 504)), + _retry_statusless_provider_errors, ), ) diff --git a/tests/test_model_retry.py b/tests/test_model_retry.py new file mode 100644 index 00000000..64b4002f --- /dev/null +++ b/tests/test_model_retry.py @@ -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 + )