From c5724638be1d65c271ca3872138e3b176b4b2dd6 Mon Sep 17 00:00:00 2001 From: 1fanwang <1fannnw@gmail.com> Date: Sat, 29 Aug 2026 03:36:05 -0400 Subject: [PATCH 1/2] Honor retry-after headers when OpenAI requests a retry The delay parsed from retry-after-ms and retry-after was discarded on the x-should-retry: true path, which re-raised the raw error instead of an ApplicationError carrying next_retry_delay. Fold the header into the shared retryable computation so every path carries the delay. Signed-off-by: 1fanwang <1fannnw@gmail.com> --- CHANGELOG.md | 5 ++ .../openai_agents/_invoke_model_activity.py | 9 +-- tests/contrib/openai_agents/test_openai.py | 64 ++++++++++++++++++- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be3444177..58b9b6c81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,11 @@ to include examples, links to docs, or any other relevant information. ### Fixed +- `temporalio.contrib.openai_agents` now honors the `retry-after-ms` and + `retry-after` headers when OpenAI returns `x-should-retry: true`. Previously + the delay the server asked for was discarded on that path and the activity + retried on its configured interval instead. + ### Security ## [1.32.0] - 2026-08-24 diff --git a/temporalio/contrib/openai_agents/_invoke_model_activity.py b/temporalio/contrib/openai_agents/_invoke_model_activity.py index 8cf735f9f..75dfc5276 100644 --- a/temporalio/contrib/openai_agents/_invoke_model_activity.py +++ b/temporalio/contrib/openai_agents/_invoke_model_activity.py @@ -311,8 +311,6 @@ def _raise_for_openai_status(e: APIStatusError) -> NoReturn: retry_after = timedelta(seconds=float(retry_after_header)) should_retry_header = e.response.headers.get("x-should-retry") - if should_retry_header == "true": - raise e if should_retry_header == "false": raise ApplicationError( "Non retryable OpenAI error", @@ -323,9 +321,12 @@ def _raise_for_openai_status(e: APIStatusError) -> NoReturn: # Retry on 408 (Request Timeout), 409 (Conflict / often transient # state mismatch), 429 (Too Many Requests / rate-limited), and any # 5xx (server-side errors). All other 4xx codes are caller errors - # that won't recover on retry. + # that won't recover on retry, unless the server explicitly asks for + # a retry via x-should-retry. retryable = ( - e.response.status_code in [408, 409, 429] or e.response.status_code >= 500 + should_retry_header == "true" + or e.response.status_code in [408, 409, 429] + or e.response.status_code >= 500 ) raise ApplicationError( f"{'Retryable' if retryable else 'Non retryable'} OpenAI status code: " diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index 79f7f9dcd..52cd3e484 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -87,7 +87,10 @@ StatefulMCPServerProvider, StatelessMCPServerProvider, ) -from temporalio.contrib.openai_agents._invoke_model_activity import _build_tool +from temporalio.contrib.openai_agents._invoke_model_activity import ( + _build_tool, + _raise_for_openai_status, +) from temporalio.contrib.openai_agents._model_parameters import ModelSummaryProvider from temporalio.contrib.openai_agents._openai_runner import ( _coerce_run_config, @@ -1484,6 +1487,65 @@ async def test_exception_handling(client: Client): await assert_status_retry_behavior(404, client, should_retry=False) +def _openai_status_error(status: int, headers: dict[str, str]) -> APIStatusError: + import httpx + + return APIStatusError( + message="Something went wrong.", + response=httpx.Response( + status_code=status, + request=httpx.Request("GET", url=""), + headers=headers, + ), + body=None, + ) + + +def test_retry_after_ms_propagated_when_server_requests_retry(): + with pytest.raises(ApplicationError) as err: + _raise_for_openai_status( + _openai_status_error( + 429, {"x-should-retry": "true", "retry-after-ms": "5000"} + ) + ) + assert not err.value.non_retryable + assert err.value.next_retry_delay == timedelta(milliseconds=5000) + + +def test_retry_after_seconds_propagated_when_server_requests_retry(): + with pytest.raises(ApplicationError) as err: + _raise_for_openai_status( + _openai_status_error(429, {"x-should-retry": "true", "retry-after": "5"}) + ) + assert not err.value.non_retryable + assert err.value.next_retry_delay == timedelta(seconds=5) + + +def test_should_retry_true_overrides_non_retryable_status(): + with pytest.raises(ApplicationError) as err: + _raise_for_openai_status(_openai_status_error(400, {"x-should-retry": "true"})) + assert not err.value.non_retryable + + +def test_should_retry_false_stays_non_retryable(): + with pytest.raises(ApplicationError) as err: + _raise_for_openai_status( + _openai_status_error( + 429, {"x-should-retry": "false", "retry-after-ms": "5000"} + ) + ) + assert err.value.non_retryable + assert err.value.next_retry_delay == timedelta(milliseconds=5000) + + +def test_retry_after_ms_takes_precedence_over_retry_after(): + with pytest.raises(ApplicationError) as err: + _raise_for_openai_status( + _openai_status_error(429, {"retry-after-ms": "1500", "retry-after": "60"}) + ) + assert err.value.next_retry_delay == timedelta(milliseconds=1500) + + class CustomModelProvider(ModelProvider): def get_model(self, model_name: str | None) -> Model: client = AsyncOpenAI(base_url="https://api.openai.com/v1") From 8911d680bd34e51ab0e6e6e5bab21b73bbedbf6c Mon Sep 17 00:00:00 2001 From: 1fanwang <1fannnw@gmail.com> Date: Sun, 30 Aug 2026 18:10:54 -0500 Subject: [PATCH 2/2] Preserve OpenAI status error type for retry policies Signed-off-by: 1fanwang <1fannnw@gmail.com> --- .../openai_agents/_invoke_model_activity.py | 10 +++++-- tests/contrib/openai_agents/test_openai.py | 29 +++++++++++++++---- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/temporalio/contrib/openai_agents/_invoke_model_activity.py b/temporalio/contrib/openai_agents/_invoke_model_activity.py index 75dfc5276..9f4bfe93c 100644 --- a/temporalio/contrib/openai_agents/_invoke_model_activity.py +++ b/temporalio/contrib/openai_agents/_invoke_model_activity.py @@ -313,7 +313,8 @@ def _raise_for_openai_status(e: APIStatusError) -> NoReturn: should_retry_header = e.response.headers.get("x-should-retry") if should_retry_header == "false": raise ApplicationError( - "Non retryable OpenAI error", + message="Non retryable OpenAI error", + type=APIStatusError.__name__, non_retryable=True, next_retry_delay=retry_after, ) from e @@ -329,8 +330,11 @@ def _raise_for_openai_status(e: APIStatusError) -> NoReturn: or e.response.status_code >= 500 ) raise ApplicationError( - f"{'Retryable' if retryable else 'Non retryable'} OpenAI status code: " - f"{e.response.status_code}", + message=( + f"{'Retryable' if retryable else 'Non retryable'} OpenAI status code: " + f"{e.response.status_code}" + ), + type=APIStatusError.__name__, non_retryable=not retryable, next_retry_delay=retry_after, ) from e diff --git a/tests/contrib/openai_agents/test_openai.py b/tests/contrib/openai_agents/test_openai.py index 52cd3e484..74a9ef04d 100644 --- a/tests/contrib/openai_agents/test_openai.py +++ b/tests/contrib/openai_agents/test_openai.py @@ -61,7 +61,7 @@ from agents.sandbox.capabilities.tools import SandboxApplyPatchTool from agents.tool import CustomTool from agents.tool_context import ToolContext -from openai import APIStatusError, AsyncOpenAI, BaseModel +from openai import APIStatusError, AsyncOpenAI, BaseModel, RateLimitError from openai.types.responses import ( ResponseCodeInterpreterToolCall, ResponseCustomToolCall, @@ -1430,12 +1430,21 @@ async def test_response_serialization(): await pydantic_data_converter.encode([model_response]) -async def assert_status_retry_behavior(status: int, client: Client, should_retry: bool): - def status_error(status: int): +async def assert_status_retry_behavior( + status: int, + client: Client, + should_retry: bool, + *, + retry_policy: RetryPolicy | None = None, +) -> None: + def status_error(status: int) -> ModelResponse: with workflow.unsafe.imports_passed_through(): with workflow.unsafe.sandbox_unrestricted(): import httpx - raise APIStatusError( + error_type: type[APIStatusError] = ( + RateLimitError if status == 429 else APIStatusError + ) + raise error_type( message="Something went wrong.", response=httpx.Response( status_code=status, request=httpx.Request("GET", url="") @@ -1446,7 +1455,7 @@ def status_error(status: int): async with AgentEnvironment( model=TestModel(lambda: status_error(status)), model_params=ModelActivityParameters( - retry_policy=RetryPolicy(maximum_attempts=2), + retry_policy=retry_policy or RetryPolicy(maximum_attempts=2), ), ) as env: client = env.applied_on_client(client) @@ -1485,6 +1494,15 @@ async def test_exception_handling(client: Client): await assert_status_retry_behavior(400, client, should_retry=False) await assert_status_retry_behavior(403, client, should_retry=False) await assert_status_retry_behavior(404, client, should_retry=False) + await assert_status_retry_behavior( + 429, + client, + should_retry=False, + retry_policy=RetryPolicy( + maximum_attempts=2, + non_retryable_error_types=["APIStatusError"], + ), + ) def _openai_status_error(status: int, headers: dict[str, str]) -> APIStatusError: @@ -1509,6 +1527,7 @@ def test_retry_after_ms_propagated_when_server_requests_retry(): ) ) assert not err.value.non_retryable + assert err.value.type == "APIStatusError" assert err.value.next_retry_delay == timedelta(milliseconds=5000)