mirror of
https://github.com/wassname/openrouter-python-sdk-retry-errors.git
synced 2026-08-01 12:40:23 +08:00
OpenRouter is a passthrough for providers it does not control, and some transient provider failures (502/503/529/...) arrive as HTTP 400 with the real status in the JSON error.code. Retry only those; ordinary 400 invalid-request responses (including inner error.code == 400) stay non-retryable. Reuses the existing TemporaryError/backoff. Note: this edits generated code because retry classification here is HTTP-status only (x-speakeasy-retries) and SDK hooks run outside the retry loop, so a 400 body cannot be inspected via an overlay or hook. Preserving it across regen needs a genignore or upstream Speakeasy support -- flagging for maintainers. Co-Authored-By: Claudypoo <288921227+claudypoo@users.noreply.github.com>
88 lines
2.7 KiB
Python
88 lines
2.7 KiB
Python
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():
|
|
# Tiny, jitter-free budget so retry() exhausts in milliseconds during tests.
|
|
backoff = BackoffStrategy(
|
|
initial_interval=1, max_interval=1, exponent=1.0, max_elapsed_time=20, 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(self):
|
|
calls = 0
|
|
|
|
def func():
|
|
nonlocal calls
|
|
calls += 1
|
|
return response(400, {"error": {"message": "Provider returned error", "code": 502}})
|
|
|
|
result = retry(func, fast_retries())
|
|
self.assertEqual(result.status_code, 400)
|
|
self.assertGreater(calls, 1) # it retried
|
|
|
|
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(self):
|
|
calls = 0
|
|
|
|
async def func():
|
|
nonlocal calls
|
|
calls += 1
|
|
return response(400, {"error": {"code": 503}})
|
|
|
|
result = asyncio.run(retry_async(func, fast_retries()))
|
|
self.assertEqual(result.status_code, 400)
|
|
self.assertGreater(calls, 1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|