Compare commits

..
Author SHA1 Message Date
wassnameandClaudypoo 4e39d32cbb 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>
2026-07-05 09:16:26 +08:00
wassnameandClaudypoo 31e1fbac0c 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>
2026-07-05 09:14:18 +08:00
wassname d159a17d88 Merge remote-tracking branch 'upstream/main' into fix/chat-transient-retries
# Conflicts:
#	.genignore
#	src/openrouter/utils/retries.py
2026-07-05 08:50:09 +08:00
wassnameandClaudypoo 12ce0d4304 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>
2026-07-05 08:32:17 +08:00
wassname (Michael J Clark)andCopilot Autofix powered by AI ef31bc8283 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-30 15:03:08 +08:00
wassname bae00aae15 Retry transient OpenRouter errors 2026-06-30 14:56:07 +08:00
4 changed files with 12 additions and 124 deletions
@@ -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"
+1
View File
@@ -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
-31
View File
@@ -1,7 +1,6 @@
"""Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT."""
import asyncio
import json
import random
import time
from datetime import datetime
@@ -151,24 +150,6 @@ def _parse_retry_after_ms_header(response: httpx.Response) -> Optional[int]:
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(
exception: Exception,
initial_interval: int,
@@ -226,12 +207,6 @@ def retry(func, retries: Retries):
if res.status_code == parsed_code:
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:
if retries.config.retry_connection_errors:
raise
@@ -277,12 +252,6 @@ async def retry_async(func, retries: Retries):
if res.status_code == parsed_code:
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:
if retries.config.retry_connection_errors:
raise
-93
View File
@@ -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()