Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 12 additions & 7 deletions temporalio/contrib/openai_agents/_invoke_model_activity.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,25 +311,30 @@ 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",
message="Non retryable OpenAI error",
type=APIStatusError.__name__,
non_retryable=True,
next_retry_delay=retry_after,
) from e

# 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: "
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
Expand Down
93 changes: 87 additions & 6 deletions tests/contrib/openai_agents/test_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -1427,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="")
Expand All @@ -1443,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)
Expand Down Expand Up @@ -1482,6 +1494,75 @@ 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:
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.type == "APIStatusError"
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):
Expand Down
Loading