From bae00aae154be625e3faa0c6d650763fdf6671f0 Mon Sep 17 00:00:00 2001 From: wassname <1103714+wassname@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:56:07 +0800 Subject: [PATCH 1/5] Retry transient OpenRouter errors --- src/openrouter/utils/retries.py | 106 ++++++++++++++++++++++++-------- tests/test_retries.py | 75 ++++++++++++++++++++++ 2 files changed, 154 insertions(+), 27 deletions(-) create mode 100644 tests/test_retries.py diff --git a/src/openrouter/utils/retries.py b/src/openrouter/utils/retries.py index 88a91b10..a8c8255e 100644 --- a/src/openrouter/utils/retries.py +++ b/src/openrouter/utils/retries.py @@ -1,11 +1,12 @@ """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 List, Optional +from typing import Any, List, Optional import httpx @@ -93,6 +94,79 @@ 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 < code_range + 1 + + 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 _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, @@ -131,19 +205,8 @@ def do_request() -> httpx.Response: try: res = func() - 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) + if _is_retryable_response(res, retries.status_codes): + raise TemporaryError(res) except httpx.ConnectError as exception: if retries.config.retry_connection_errors: raise @@ -180,19 +243,8 @@ async def do_request() -> httpx.Response: try: res = await func() - 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) + if await _is_retryable_response_async(res, retries.status_codes): + raise TemporaryError(res) except httpx.ConnectError as exception: if retries.config.retry_connection_errors: raise diff --git a/tests/test_retries.py b/tests/test_retries.py new file mode 100644 index 00000000..b6b5652d --- /dev/null +++ b/tests/test_retries.py @@ -0,0 +1,75 @@ +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() From ef31bc82837f1c53c2aef4836e35e827557b59e1 Mon Sep 17 00:00:00 2001 From: "wassname (Michael J Clark)" <1103714+wassname@users.noreply.github.com> Date: Tue, 30 Jun 2026 15:03:08 +0800 Subject: [PATCH 2/5] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/openrouter/utils/retries.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/openrouter/utils/retries.py b/src/openrouter/utils/retries.py index a8c8255e..626256e6 100644 --- a/src/openrouter/utils/retries.py +++ b/src/openrouter/utils/retries.py @@ -101,8 +101,8 @@ def _parse_retry_after_header(response: httpx.Response) -> Optional[int]: 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 < code_range + 1 + status_major = response.status_code // 100 + return code_range == status_major return response.status_code == int(status_code) From 12ce0d43049baca65b8be03e90be7569fe32bda9 Mon Sep 17 00:00:00 2001 From: wassname <1103714+wassname@users.noreply.github.com> Date: Sun, 5 Jul 2026 08:32:17 +0800 Subject: [PATCH 3/5] Genignore utils/retries.py so speakeasy generate keeps the retry changes The retry response classification (408/429/5XX + transient inner error.code on 400s) has no overlay or hook surface in Speakeasy, so retries.py must be hand-maintained. It is tracked in gen.lock, so without a genignore entry the next `speakeasy generate` overwrites this PR's changes. pylintrc is already genignored the same way as precedent. Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com> --- .genignore | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.genignore b/.genignore index 49840533..858ae1be 100644 --- a/.genignore +++ b/.genignore @@ -1 +1,5 @@ -pylintrc \ No newline at end of file +pylintrc +# 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 \ No newline at end of file From 31e1fbac0cdc45841cae1d661b1e6df5b4bba41e Mon Sep 17 00:00:00 2001 From: wassname Date: Sun, 5 Jul 2026 09:14:18 +0800 Subject: [PATCH 4/5] 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 43cea29c..492fe6c2 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 9bd7bb44..ca7b59ef 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 do_request() -> httpx.Response: 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 do_request() -> httpx.Response: 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 b6b5652d..00000000 --- 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() From 4e39d32cbb8fc0d8b92d127db7f6464ac8576bd2 Mon Sep 17 00:00:00 2001 From: wassname Date: Sun, 5 Jul 2026 09:16:26 +0800 Subject: [PATCH 5/5] Add the retry overlay file and register it in workflow.yaml Completes the prior commit: 408/429 join 5XX via x-speakeasy-retries overlay. Verified offline with `speakeasy overlay apply`: statusCodes -> [5XX, 408, 429]. Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com> --- .../retry-transient-status-codes.overlay.yaml | 11 +++++++++++ .speakeasy/workflow.yaml | 1 + 2 files changed, 12 insertions(+) create mode 100644 .speakeasy/overlays/retry-transient-status-codes.overlay.yaml diff --git a/.speakeasy/overlays/retry-transient-status-codes.overlay.yaml b/.speakeasy/overlays/retry-transient-status-codes.overlay.yaml new file mode 100644 index 00000000..7ea97541 --- /dev/null +++ b/.speakeasy/overlays/retry-transient-status-codes.overlay.yaml @@ -0,0 +1,11 @@ +overlay: 1.0.0 +x-speakeasy-jsonpath: rfc9535 +info: + title: Retry request-timeout and rate-limit responses + version: 0.0.0 +actions: + - target: $["x-speakeasy-retries"].statusCodes + description: Also retry 408 (request timeout) and 429 (rate limit), not just 5XX + update: + - "408" + - "429" diff --git a/.speakeasy/workflow.yaml b/.speakeasy/workflow.yaml index 36493c14..b424293f 100644 --- a/.speakeasy/workflow.yaml +++ b/.speakeasy/workflow.yaml @@ -11,6 +11,7 @@ sources: - location: .speakeasy/overlays/allof-simplify.overlay.yaml - location: .speakeasy/overlays/boolean-query-params.overlay.yaml - location: .speakeasy/overlays/fix-nullable-pagination.overlay.yaml + - location: .speakeasy/overlays/retry-transient-status-codes.overlay.yaml output: .speakeasy/out.openapi.yaml registry: location: registry.speakeasyapi.dev/openrouter/sdk/open-router-chat-completions-api