Skip to content
Open
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
31 changes: 31 additions & 0 deletions src/openrouter/utils/retries.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT."""

import asyncio
import json
Comment on lines 1 to +4

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deliberate, not an oversight, see the "Heads-up for maintainers" in the PR body. No overlay/hook can do body-based retries here (retry config is HTTP-status only; hooks run outside the retry loop), so persisting it across regen is a maintainer call: genignore or Speakeasy template support. -- Claude

import random
import time
from datetime import datetime
Expand Down Expand Up @@ -150,6 +151,24 @@ def _parse_retry_after_ms_header(response: httpx.Response) -> Optional[int]:
return None


TRANSIENT_INNER_ERROR_CODES = {408, 429, 500, 502, 503, 504, 524, 529}


def _wraps_transient_inner_code(response: httpx.Response) -> bool:
"""OpenRouter (a middleman) sometimes wraps a transient upstream failure in an HTTP
400 whose JSON error.code holds the real status; retry those. Ordinary 400
invalid-request responses (including inner error.code == 400) stay non-retryable."""
try:
body = response.json()
except (json.JSONDecodeError, httpx.ResponseNotRead):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] retries.py:163 — malformed 400 body (invalid encoding) can raise UnicodeDecodeError instead of being treated as non-retryable

Details

Why: _wraps_transient_inner_code() only catches json.JSONDecodeError and httpx.ResponseNotRead around response.json(). But httpx.Response.json() calls json.loads(self.content, ...), and CPython's json.loads first attempts to auto-detect a UTF-16/UTF-32 BOM before falling back to UTF-8 — a garbled/non-UTF8 400 body (plausible on a passthrough middleman receiving a truncated or malformed upstream response) can trigger a UnicodeDecodeError there, which is not one of the two caught exception types. Verified locally against this branch:

>>> httpx.Response(400, content=b"\xff\xfe\x00\x01\x02", request=req).json()
UnicodeDecodeError: 'utf-16-le' codec can't decode byte 0x02 in position 4: truncated data

That exception escapes _wraps_transient_inner_code, propagates through retry()'s do_request(), gets wrapped in PermanentError, and retry_with_backoff re-raises exception.inner — so callers see a raw UnicodeDecodeError instead of the normal BadRequestResponseError (or a legitimate retry). This is a behavior regression versus pre-PR: previously every 400 fell straight through to the existing error-unmarshalling path, which reads .text (never raises) rather than .json().

Fix:

    try:
        body = response.json()
    except (json.JSONDecodeError, UnicodeDecodeError, httpx.ResponseNotRead):
        return False

Ref: json.loads encoding detection — bytes input is auto-detected for BOM/encoding before UTF-8 fallback, which is where the malformed input raises.

Prompt for agents
In src/openrouter/utils/retries.py:163, _wraps_transient_inner_code() catches (json.JSONDecodeError, httpx.ResponseNotRead) around response.json(), but a malformed/non-UTF8 400 body can make json.loads raise UnicodeDecodeError during its BOM/encoding sniffing before it ever attempts to parse JSON — that exception type is not caught. Widen the except tuple at retries.py:163 to also include UnicodeDecodeError (or replace both JSONDecodeError and UnicodeDecodeError with their common base ValueError), so a garbled 400 body is classified non-retryable instead of leaking a raw UnicodeDecodeError out of retry()/retry_async() to the caller. Add a regression test in tests/test_retries.py asserting _wraps_transient_inner_code(response(400, content=b"\xff\xfe\x00\x01\x02")) returns False without raising.

Reviewed at 0a691a1

return False
error = body.get("error") if isinstance(body, dict) else None
code = error.get("code") if isinstance(error, dict) else None
if isinstance(code, str) and code.isdigit():
code = int(code)
return code in TRANSIENT_INNER_ERROR_CODES


def _get_sleep_interval(
exception: Exception,
initial_interval: int,
Expand Down Expand Up @@ -207,6 +226,12 @@ def do_request() -> httpx.Response:

if res.status_code == parsed_code:
raise TemporaryError(res)

if res.status_code == 400:
if not res.is_closed:
res.read()
if _wraps_transient_inner_code(res):
raise TemporaryError(res)
except (httpx.NetworkError, httpx.TimeoutException) as exception:
if retries.config.retry_connection_errors:
raise
Expand Down Expand Up @@ -252,6 +277,12 @@ async def do_request() -> httpx.Response:

if res.status_code == parsed_code:
raise TemporaryError(res)

if res.status_code == 400:
if not res.is_closed:
await res.aread()
if _wraps_transient_inner_code(res):
raise TemporaryError(res)
except (httpx.NetworkError, httpx.TimeoutException) as exception:
if retries.config.retry_connection_errors:
raise
Expand Down
93 changes: 93 additions & 0 deletions tests/test_retries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import asyncio
import unittest

import httpx

from openrouter.utils.retries import (
BackoffStrategy,
RetryConfig,
Retries,
_wraps_transient_inner_code,
retry,
retry_async,
)


def response(status_code, json_body=None):
request = httpx.Request("POST", "https://openrouter.ai/api/v1/chat/completions")
if json_body is None:
return httpx.Response(status_code, request=request)
return httpx.Response(status_code, json=json_body, request=request)


def fast_retries():
# Jitter-free, 1ms intervals. Budget is generous so these tests terminate on
# success / non-retryable status rather than on the elapsed-time timeout, which
# keeps `calls` counts deterministic regardless of CI scheduling overhead.
backoff = BackoffStrategy(
initial_interval=1, max_interval=1, exponent=1.0, max_elapsed_time=10000, jitter_ms=0
)
return Retries(RetryConfig("backoff", backoff, retry_connection_errors=True), ["5XX"])


class WrapsTransientInnerCodeTests(unittest.TestCase):
def test_400_with_transient_inner_code(self):
self.assertTrue(_wraps_transient_inner_code(response(400, {"error": {"code": 502}})))

def test_400_with_string_inner_code(self):
self.assertTrue(_wraps_transient_inner_code(response(400, {"error": {"code": "529"}})))

def test_400_invalid_request_inner_400_not_retried(self):
self.assertFalse(_wraps_transient_inner_code(response(400, {"error": {"code": 400}})))

def test_400_without_inner_code(self):
self.assertFalse(_wraps_transient_inner_code(response(400, {"error": {"message": "bad"}})))

def test_400_non_json_body(self):
self.assertFalse(_wraps_transient_inner_code(response(400)))


class RetryIntegrationTests(unittest.TestCase):
def test_retries_400_wrapping_transient_code_then_succeeds(self):
calls = 0

def func():
nonlocal calls
calls += 1
if calls < 3:
return response(400, {"error": {"message": "Provider returned error", "code": 502}})
return response(200, {"ok": True})

result = retry(func, fast_retries())
self.assertEqual(result.status_code, 200)
self.assertEqual(calls, 3) # retried twice, then succeeded

def test_does_not_retry_plain_400(self):
calls = 0

def func():
nonlocal calls
calls += 1
return response(400, {"error": {"message": "logprobs must be 0-5", "code": 400}})

result = retry(func, fast_retries())
self.assertEqual(result.status_code, 400)
self.assertEqual(calls, 1) # not retried

def test_async_retries_400_wrapping_transient_code_then_succeeds(self):
calls = 0

async def func():
nonlocal calls
calls += 1
if calls < 3:
return response(400, {"error": {"code": 503}})
return response(200, {"ok": True})

result = asyncio.run(retry_async(func, fast_retries()))
self.assertEqual(result.status_code, 200)
self.assertEqual(calls, 3)


if __name__ == "__main__":
unittest.main()