Retry 408/429 via x-speakeasy-retries overlay instead of editing generated code

Reverts the hand-edits to the generated utils/retries.py, its .genignore entry, and
the test. Adds an overlay so 408/429 join 5XX in the retry config, regenerating cleanly
per CONTRIBUTING (generated code is not edited directly). Verified with
`speakeasy overlay apply`: statusCodes -> [5XX, 408, 429].

The 400-with-transient-inner-error.code case is not portable to the SDK (status-only
retry config, hooks run outside the retry loop) and is handled client-side instead.

Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
This commit is contained in:
wassname
2026-07-05 09:14:18 +08:00
co-authored by Claudypoo
parent d159a17d88
commit 31e1fbac0c
3 changed files with 27 additions and 158 deletions
-4
View File
@@ -1,7 +1,3 @@
pylintrc pylintrc
docs/docs.json docs/docs.json
docs/overview.mdx docs/overview.mdx
# Owns retry response classification (408/429/5XX + transient inner error.code on 400s).
# Speakeasy has no overlay/hook surface for the 400-inner-code case, so this file is
# hand-maintained; genignore stops `speakeasy generate` from overwriting it. -- Claude
src/openrouter/utils/retries.py
+27 -79
View File
@@ -1,12 +1,11 @@
"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT."""
import asyncio import asyncio
import json
import random import random
import time import time
from datetime import datetime from datetime import datetime
from email.utils import parsedate_to_datetime from email.utils import parsedate_to_datetime
from typing import Any, List, Optional from typing import List, Optional
import httpx import httpx
@@ -136,41 +135,6 @@ def _parse_retry_after_header(response: httpx.Response) -> Optional[int]:
return None return None
TRANSIENT_STATUS_CODES = {408, 429}
TRANSIENT_ERROR_CODES = {408, 429, 500, 502, 503, 504, 524, 529}
def _matches_retry_status_code(response: httpx.Response, status_code: str) -> bool:
if "X" in status_code.upper():
code_range = int(status_code[0])
status_major = response.status_code // 100
return code_range == status_major
return response.status_code == int(status_code)
def _error_code_from_json_body(response: httpx.Response) -> Optional[int]:
try:
body: Any = response.json()
except json.JSONDecodeError:
return None
if not isinstance(body, dict):
return None
error = body.get("error")
if not isinstance(error, dict):
return None
code = error.get("code")
if isinstance(code, int):
return code
if isinstance(code, str) and code.isdigit():
return int(code)
return None
def _parse_retry_after_ms_header(response: httpx.Response) -> Optional[int]: def _parse_retry_after_ms_header(response: httpx.Response) -> Optional[int]:
retry_after_ms_header = response.headers.get("retry-after-ms") retry_after_ms_header = response.headers.get("retry-after-ms")
if not retry_after_ms_header: if not retry_after_ms_header:
@@ -186,44 +150,6 @@ def _parse_retry_after_ms_header(response: httpx.Response) -> Optional[int]:
return None return None
def _is_retryable_response(response: httpx.Response, status_codes: List[str]) -> bool:
if response.status_code in TRANSIENT_STATUS_CODES:
return True
for status_code in status_codes:
if _matches_retry_status_code(response, status_code):
return True
if response.status_code != 400:
return False
if not response.is_closed:
response.read()
inner_code = _error_code_from_json_body(response)
return inner_code in TRANSIENT_ERROR_CODES
async def _is_retryable_response_async(
response: httpx.Response, status_codes: List[str]
) -> bool:
if response.status_code in TRANSIENT_STATUS_CODES:
return True
for status_code in status_codes:
if _matches_retry_status_code(response, status_code):
return True
if response.status_code != 400:
return False
if not response.is_closed:
await response.aread()
inner_code = _error_code_from_json_body(response)
return inner_code in TRANSIENT_ERROR_CODES
def _get_sleep_interval( def _get_sleep_interval(
exception: Exception, exception: Exception,
initial_interval: int, initial_interval: int,
@@ -268,8 +194,19 @@ def retry(func, retries: Retries):
try: try:
res = func() res = func()
if _is_retryable_response(res, retries.status_codes): for code in retries.status_codes:
raise TemporaryError(res) if "X" in code.upper():
code_range = int(code[0])
status_major = res.status_code / 100
if code_range <= status_major < code_range + 1:
raise TemporaryError(res)
else:
parsed_code = int(code)
if res.status_code == parsed_code:
raise TemporaryError(res)
except (httpx.NetworkError, httpx.TimeoutException) as exception: except (httpx.NetworkError, httpx.TimeoutException) as exception:
if retries.config.retry_connection_errors: if retries.config.retry_connection_errors:
raise raise
@@ -302,8 +239,19 @@ async def retry_async(func, retries: Retries):
try: try:
res = await func() res = await func()
if await _is_retryable_response_async(res, retries.status_codes): for code in retries.status_codes:
raise TemporaryError(res) if "X" in code.upper():
code_range = int(code[0])
status_major = res.status_code / 100
if code_range <= status_major < code_range + 1:
raise TemporaryError(res)
else:
parsed_code = int(code)
if res.status_code == parsed_code:
raise TemporaryError(res)
except (httpx.NetworkError, httpx.TimeoutException) as exception: except (httpx.NetworkError, httpx.TimeoutException) as exception:
if retries.config.retry_connection_errors: if retries.config.retry_connection_errors:
raise raise
-75
View File
@@ -1,75 +0,0 @@
import unittest
import httpx
from openrouter.utils.retries import (
_is_retryable_response,
_is_retryable_response_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)
class RetryResponseTests(unittest.TestCase):
def test_retries_request_timeout_and_rate_limit_by_default(self):
self.assertTrue(_is_retryable_response(response(408), []))
self.assertTrue(_is_retryable_response(response(429), []))
def test_retries_configured_status_code_patterns(self):
self.assertTrue(_is_retryable_response(response(502), ["5XX"]))
self.assertTrue(_is_retryable_response(response(529), ["5XX"]))
self.assertFalse(_is_retryable_response(response(400), ["5XX"]))
def test_retries_400_with_transient_inner_error_code(self):
res = response(
400,
{
"error": {
"message": "Provider returned error",
"code": 502,
"metadata": {"provider_name": "example"},
}
},
)
self.assertTrue(_is_retryable_response(res, ["5XX"]))
self.assertEqual(res.json()["error"]["code"], 502)
def test_does_not_retry_400_with_non_transient_inner_error_code(self):
res = response(
400,
{
"error": {
"message": "logprobs must be between 0 and 5",
"code": 400,
}
},
)
self.assertFalse(_is_retryable_response(res, ["5XX"]))
class AsyncRetryResponseTests(unittest.IsolatedAsyncioTestCase):
async def test_retries_400_with_transient_inner_error_code_async(self):
res = response(
400,
{
"error": {
"message": "Provider returned error",
"code": 502,
"metadata": {"provider_name": "example"},
}
},
)
self.assertTrue(await _is_retryable_response_async(res, ["5XX"]))
self.assertEqual(res.json()["error"]["code"], 502)
if __name__ == "__main__":
unittest.main()