mirror of
https://github.com/wassname/openrouter-python-sdk-retry-errors.git
synced 2026-09-11 12:31:28 +08:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4e39d32cbb | ||
|
|
31e1fbac0c | ||
|
|
d159a17d88 | ||
|
|
12ce0d4304 | ||
|
|
ef31bc8283 | ||
|
|
bae00aae15 |
@@ -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"
|
||||||
@@ -11,6 +11,7 @@ sources:
|
|||||||
- location: .speakeasy/overlays/allof-simplify.overlay.yaml
|
- location: .speakeasy/overlays/allof-simplify.overlay.yaml
|
||||||
- location: .speakeasy/overlays/boolean-query-params.overlay.yaml
|
- location: .speakeasy/overlays/boolean-query-params.overlay.yaml
|
||||||
- location: .speakeasy/overlays/fix-nullable-pagination.overlay.yaml
|
- location: .speakeasy/overlays/fix-nullable-pagination.overlay.yaml
|
||||||
|
- location: .speakeasy/overlays/retry-transient-status-codes.overlay.yaml
|
||||||
output: .speakeasy/out.openapi.yaml
|
output: .speakeasy/out.openapi.yaml
|
||||||
registry:
|
registry:
|
||||||
location: registry.speakeasyapi.dev/openrouter/sdk/open-router-chat-completions-api
|
location: registry.speakeasyapi.dev/openrouter/sdk/open-router-chat-completions-api
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""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
|
||||||
@@ -151,24 +150,6 @@ def _parse_retry_after_ms_header(response: httpx.Response) -> Optional[int]:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
TRANSIENT_INNER_ERROR_CODES = {408, 429, 500, 502, 503, 504, 524, 529}
|
|
||||||
|
|
||||||
|
|
||||||
def _wraps_transient_inner_code(response: httpx.Response) -> bool:
|
|
||||||
"""OpenRouter (a middleman) sometimes wraps a transient upstream failure in an HTTP
|
|
||||||
400 whose JSON error.code holds the real status; retry those. Ordinary 400
|
|
||||||
invalid-request responses (including inner error.code == 400) stay non-retryable."""
|
|
||||||
try:
|
|
||||||
body = response.json()
|
|
||||||
except (json.JSONDecodeError, httpx.ResponseNotRead):
|
|
||||||
return False
|
|
||||||
error = body.get("error") if isinstance(body, dict) else None
|
|
||||||
code = error.get("code") if isinstance(error, dict) else None
|
|
||||||
if isinstance(code, str) and code.isdigit():
|
|
||||||
code = int(code)
|
|
||||||
return code in TRANSIENT_INNER_ERROR_CODES
|
|
||||||
|
|
||||||
|
|
||||||
def _get_sleep_interval(
|
def _get_sleep_interval(
|
||||||
exception: Exception,
|
exception: Exception,
|
||||||
initial_interval: int,
|
initial_interval: int,
|
||||||
@@ -226,12 +207,6 @@ def retry(func, retries: Retries):
|
|||||||
|
|
||||||
if res.status_code == parsed_code:
|
if res.status_code == parsed_code:
|
||||||
raise TemporaryError(res)
|
raise TemporaryError(res)
|
||||||
|
|
||||||
if res.status_code == 400:
|
|
||||||
if not res.is_closed:
|
|
||||||
res.read()
|
|
||||||
if _wraps_transient_inner_code(res):
|
|
||||||
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
|
||||||
@@ -277,12 +252,6 @@ async def retry_async(func, retries: Retries):
|
|||||||
|
|
||||||
if res.status_code == parsed_code:
|
if res.status_code == parsed_code:
|
||||||
raise TemporaryError(res)
|
raise TemporaryError(res)
|
||||||
|
|
||||||
if res.status_code == 400:
|
|
||||||
if not res.is_closed:
|
|
||||||
await res.aread()
|
|
||||||
if _wraps_transient_inner_code(res):
|
|
||||||
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
|
||||||
|
|||||||
@@ -1,93 +0,0 @@
|
|||||||
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():
|
|
||||||
# Jitter-free, 1ms intervals. Budget is generous so these tests terminate on
|
|
||||||
# success / non-retryable status rather than on the elapsed-time timeout, which
|
|
||||||
# keeps `calls` counts deterministic regardless of CI scheduling overhead.
|
|
||||||
backoff = BackoffStrategy(
|
|
||||||
initial_interval=1, max_interval=1, exponent=1.0, max_elapsed_time=10000, 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_then_succeeds(self):
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
def func():
|
|
||||||
nonlocal calls
|
|
||||||
calls += 1
|
|
||||||
if calls < 3:
|
|
||||||
return response(400, {"error": {"message": "Provider returned error", "code": 502}})
|
|
||||||
return response(200, {"ok": True})
|
|
||||||
|
|
||||||
result = retry(func, fast_retries())
|
|
||||||
self.assertEqual(result.status_code, 200)
|
|
||||||
self.assertEqual(calls, 3) # retried twice, then succeeded
|
|
||||||
|
|
||||||
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_then_succeeds(self):
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
async def func():
|
|
||||||
nonlocal calls
|
|
||||||
calls += 1
|
|
||||||
if calls < 3:
|
|
||||||
return response(400, {"error": {"code": 503}})
|
|
||||||
return response(200, {"ok": True})
|
|
||||||
|
|
||||||
result = asyncio.run(retry_async(func, fast_retries()))
|
|
||||||
self.assertEqual(result.status_code, 200)
|
|
||||||
self.assertEqual(calls, 3)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
unittest.main()
|
|
||||||
Reference in New Issue
Block a user