Skip to content

Commit bae00aa

Browse files
committed
Retry transient OpenRouter errors
1 parent 778e2e3 commit bae00aa

2 files changed

Lines changed: 154 additions & 27 deletions

File tree

src/openrouter/utils/retries.py

Lines changed: 79 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT."""
22

33
import asyncio
4+
import json
45
import random
56
import time
67
from datetime import datetime
78
from email.utils import parsedate_to_datetime
8-
from typing import List, Optional
9+
from typing import Any, List, Optional
910

1011
import httpx
1112

@@ -93,6 +94,79 @@ def _parse_retry_after_header(response: httpx.Response) -> Optional[int]:
9394
return None
9495

9596

97+
TRANSIENT_STATUS_CODES = {408, 429}
98+
TRANSIENT_ERROR_CODES = {408, 429, 500, 502, 503, 504, 524, 529}
99+
100+
101+
def _matches_retry_status_code(response: httpx.Response, status_code: str) -> bool:
102+
if "X" in status_code.upper():
103+
code_range = int(status_code[0])
104+
status_major = response.status_code / 100
105+
return code_range <= status_major < code_range + 1
106+
107+
return response.status_code == int(status_code)
108+
109+
110+
def _error_code_from_json_body(response: httpx.Response) -> Optional[int]:
111+
try:
112+
body: Any = response.json()
113+
except json.JSONDecodeError:
114+
return None
115+
116+
if not isinstance(body, dict):
117+
return None
118+
119+
error = body.get("error")
120+
if not isinstance(error, dict):
121+
return None
122+
123+
code = error.get("code")
124+
if isinstance(code, int):
125+
return code
126+
if isinstance(code, str) and code.isdigit():
127+
return int(code)
128+
129+
return None
130+
131+
132+
def _is_retryable_response(response: httpx.Response, status_codes: List[str]) -> bool:
133+
if response.status_code in TRANSIENT_STATUS_CODES:
134+
return True
135+
136+
for status_code in status_codes:
137+
if _matches_retry_status_code(response, status_code):
138+
return True
139+
140+
if response.status_code != 400:
141+
return False
142+
143+
if not response.is_closed:
144+
response.read()
145+
146+
inner_code = _error_code_from_json_body(response)
147+
return inner_code in TRANSIENT_ERROR_CODES
148+
149+
150+
async def _is_retryable_response_async(
151+
response: httpx.Response, status_codes: List[str]
152+
) -> bool:
153+
if response.status_code in TRANSIENT_STATUS_CODES:
154+
return True
155+
156+
for status_code in status_codes:
157+
if _matches_retry_status_code(response, status_code):
158+
return True
159+
160+
if response.status_code != 400:
161+
return False
162+
163+
if not response.is_closed:
164+
await response.aread()
165+
166+
inner_code = _error_code_from_json_body(response)
167+
return inner_code in TRANSIENT_ERROR_CODES
168+
169+
96170
def _get_sleep_interval(
97171
exception: Exception,
98172
initial_interval: int,
@@ -131,19 +205,8 @@ def do_request() -> httpx.Response:
131205
try:
132206
res = func()
133207

134-
for code in retries.status_codes:
135-
if "X" in code.upper():
136-
code_range = int(code[0])
137-
138-
status_major = res.status_code / 100
139-
140-
if code_range <= status_major < code_range + 1:
141-
raise TemporaryError(res)
142-
else:
143-
parsed_code = int(code)
144-
145-
if res.status_code == parsed_code:
146-
raise TemporaryError(res)
208+
if _is_retryable_response(res, retries.status_codes):
209+
raise TemporaryError(res)
147210
except httpx.ConnectError as exception:
148211
if retries.config.retry_connection_errors:
149212
raise
@@ -180,19 +243,8 @@ async def do_request() -> httpx.Response:
180243
try:
181244
res = await func()
182245

183-
for code in retries.status_codes:
184-
if "X" in code.upper():
185-
code_range = int(code[0])
186-
187-
status_major = res.status_code / 100
188-
189-
if code_range <= status_major < code_range + 1:
190-
raise TemporaryError(res)
191-
else:
192-
parsed_code = int(code)
193-
194-
if res.status_code == parsed_code:
195-
raise TemporaryError(res)
246+
if await _is_retryable_response_async(res, retries.status_codes):
247+
raise TemporaryError(res)
196248
except httpx.ConnectError as exception:
197249
if retries.config.retry_connection_errors:
198250
raise

tests/test_retries.py

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import unittest
2+
3+
import httpx
4+
5+
from openrouter.utils.retries import (
6+
_is_retryable_response,
7+
_is_retryable_response_async,
8+
)
9+
10+
11+
def response(status_code, json_body=None):
12+
request = httpx.Request("POST", "https://openrouter.ai/api/v1/chat/completions")
13+
if json_body is None:
14+
return httpx.Response(status_code, request=request)
15+
return httpx.Response(status_code, json=json_body, request=request)
16+
17+
18+
class RetryResponseTests(unittest.TestCase):
19+
def test_retries_request_timeout_and_rate_limit_by_default(self):
20+
self.assertTrue(_is_retryable_response(response(408), []))
21+
self.assertTrue(_is_retryable_response(response(429), []))
22+
23+
def test_retries_configured_status_code_patterns(self):
24+
self.assertTrue(_is_retryable_response(response(502), ["5XX"]))
25+
self.assertTrue(_is_retryable_response(response(529), ["5XX"]))
26+
self.assertFalse(_is_retryable_response(response(400), ["5XX"]))
27+
28+
def test_retries_400_with_transient_inner_error_code(self):
29+
res = response(
30+
400,
31+
{
32+
"error": {
33+
"message": "Provider returned error",
34+
"code": 502,
35+
"metadata": {"provider_name": "example"},
36+
}
37+
},
38+
)
39+
40+
self.assertTrue(_is_retryable_response(res, ["5XX"]))
41+
self.assertEqual(res.json()["error"]["code"], 502)
42+
43+
def test_does_not_retry_400_with_non_transient_inner_error_code(self):
44+
res = response(
45+
400,
46+
{
47+
"error": {
48+
"message": "logprobs must be between 0 and 5",
49+
"code": 400,
50+
}
51+
},
52+
)
53+
54+
self.assertFalse(_is_retryable_response(res, ["5XX"]))
55+
56+
57+
class AsyncRetryResponseTests(unittest.IsolatedAsyncioTestCase):
58+
async def test_retries_400_with_transient_inner_error_code_async(self):
59+
res = response(
60+
400,
61+
{
62+
"error": {
63+
"message": "Provider returned error",
64+
"code": 502,
65+
"metadata": {"provider_name": "example"},
66+
}
67+
},
68+
)
69+
70+
self.assertTrue(await _is_retryable_response_async(res, ["5XX"]))
71+
self.assertEqual(res.json()["error"]["code"], 502)
72+
73+
74+
if __name__ == "__main__":
75+
unittest.main()

0 commit comments

Comments
 (0)