From 31e1fbac0cdc45841cae1d661b1e6df5b4bba41e Mon Sep 17 00:00:00 2001 From: wassname Date: Sun, 5 Jul 2026 09:14:18 +0800 Subject: [PATCH] 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> --- .genignore | 4 -- src/openrouter/utils/retries.py | 106 ++++++++------------------------ tests/test_retries.py | 75 ---------------------- 3 files changed, 27 insertions(+), 158 deletions(-) delete mode 100644 tests/test_retries.py diff --git a/.genignore b/.genignore index 43cea29..492fe6c 100644 --- a/.genignore +++ b/.genignore @@ -1,7 +1,3 @@ pylintrc docs/docs.json 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 diff --git a/src/openrouter/utils/retries.py b/src/openrouter/utils/retries.py index 9bd7bb4..ca7b59e 100644 --- a/src/openrouter/utils/retries.py +++ b/src/openrouter/utils/retries.py @@ -1,12 +1,11 @@ """Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.""" import asyncio -import json import random import time from datetime import datetime from email.utils import parsedate_to_datetime -from typing import Any, List, Optional +from typing import List, Optional import httpx @@ -136,41 +135,6 @@ def _parse_retry_after_header(response: httpx.Response) -> Optional[int]: 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]: retry_after_ms_header = response.headers.get("retry-after-ms") if not retry_after_ms_header: @@ -186,44 +150,6 @@ def _parse_retry_after_ms_header(response: httpx.Response) -> Optional[int]: 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( exception: Exception, initial_interval: int, @@ -268,8 +194,19 @@ def retry(func, retries: Retries): try: res = func() - if _is_retryable_response(res, retries.status_codes): - raise TemporaryError(res) + for code in retries.status_codes: + 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: if retries.config.retry_connection_errors: raise @@ -302,8 +239,19 @@ async def retry_async(func, retries: Retries): try: res = await func() - if await _is_retryable_response_async(res, retries.status_codes): - raise TemporaryError(res) + for code in retries.status_codes: + 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: if retries.config.retry_connection_errors: raise diff --git a/tests/test_retries.py b/tests/test_retries.py deleted file mode 100644 index b6b5652..0000000 --- a/tests/test_retries.py +++ /dev/null @@ -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()