Files
openai--openai-agents-python/tests/models/test_openai_retry_helpers.py
T
2026-07-13 12:39:17 +08:00

179 lines
6.2 KiB
Python

"""Unit tests for the low-level helpers in :mod:`agents.models._openai_retry`.
These exercise the header-parsing, status-extraction, and error-code helpers
directly, plus a few public ``get_openai_retry_advice`` branches that the broader
behavioral suite in ``test_model_retry.py`` does not reach.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from email.utils import format_datetime
import httpx
from agents.models._openai_retry import get_openai_retry_advice
from agents.models._retry_runtime import (
get_error_code as _get_error_code,
get_error_header as _get_header_value,
get_retry_after,
get_status_code as _get_status_code,
header_lookup as _header_lookup,
parse_retry_after_ms as _parse_retry_after_ms,
parse_retry_after_value as _parse_retry_after,
)
from agents.retry import ModelRetryAdviceRequest
from agents.run_internal.model_retry import _normalize_retry_error
class _HeaderError(Exception):
"""Error that exposes headers through a plain attribute rather than a response."""
def __init__(self, message: str, *, headers: dict[str, str] | None = None) -> None:
super().__init__(message)
if headers is not None:
self.headers = headers
def _make_request(error: Exception, **kwargs: object) -> ModelRetryAdviceRequest:
return ModelRetryAdviceRequest(error=error, attempt=1, stream=False, **kwargs) # type: ignore[arg-type]
def test_header_lookup_plain_mapping_matches_case_insensitively() -> None:
headers = {"Retry-After": "5", "X-Other": "ignored"}
assert _header_lookup(headers, "retry-after") == "5"
assert _header_lookup(headers, "missing") is None
def test_header_lookup_httpx_headers() -> None:
headers = httpx.Headers({"retry-after": "7"})
assert _header_lookup(headers, "retry-after") == "7"
assert _header_lookup(None, "retry-after") is None
def test_get_header_value_reads_response_headers_attr() -> None:
class _Err(Exception):
response_headers = {"retry-after": "3"}
assert _get_header_value(_Err("boom"), "retry-after") == "3"
def test_parse_retry_after_ms_invalid_returns_none() -> None:
assert _parse_retry_after_ms(None) is None
assert _parse_retry_after_ms("not-a-number") is None
assert _parse_retry_after_ms("-100") is None
assert _parse_retry_after_ms("1500") == 1.5
def test_parse_retry_after_numeric_and_http_date() -> None:
assert _parse_retry_after(None) is None
assert _parse_retry_after("2") == 2.0
assert _parse_retry_after("-1") is None
future = datetime.now(timezone.utc) + timedelta(seconds=120)
parsed = _parse_retry_after(format_datetime(future))
assert parsed is not None and parsed > 0
assert _parse_retry_after("definitely not a date") is None
def test_get_retry_after_preserves_outer_exception_precedence() -> None:
outer = _HeaderError("wrapped", headers={"retry-after": "2"})
outer.__cause__ = _HeaderError("provider", headers={"retry-after-ms": "1500"})
assert get_retry_after(outer) == 2.0
def test_get_status_code_from_status_code_and_status_attrs() -> None:
class _StatusCode(Exception):
status_code = 503
class _Status(Exception):
status = 504
assert _get_status_code(_StatusCode("a")) == 503
assert _get_status_code(_Status("b")) == 504
assert _get_status_code(Exception("none")) is None
def test_get_error_code_from_body_mapping() -> None:
class _NestedBody(Exception):
body = {"error": {"code": "rate_limit_exceeded"}}
class _TopLevelBody(Exception):
body = {"code": "server_error"}
assert _get_error_code(_NestedBody("a")) == "rate_limit_exceeded"
assert _get_error_code(_TopLevelBody("b")) == "server_error"
assert _get_error_code(Exception("none")) is None
def test_provider_and_runner_retry_normalization_share_metadata() -> None:
class _RetryableError(Exception):
status_code = 429
request_id = "req_test"
body = {"error": {"code": "rate_limit_exceeded"}}
headers = {"retry-after-ms": "1500"}
class _WrapperError(Exception):
headers = {"x-other": "ignored"}
error = _WrapperError("wrapped")
error.__cause__ = _RetryableError("slow down")
advice = get_openai_retry_advice(_make_request(error))
runner_normalized = _normalize_retry_error(error, None)
assert advice is not None
assert advice.normalized is not None
assert advice.normalized.status_code == runner_normalized.status_code
assert advice.normalized.error_code == runner_normalized.error_code
assert advice.normalized.request_id == runner_normalized.request_id
assert advice.normalized.retry_after == runner_normalized.retry_after
assert runner_normalized.retry_after == 1.5
def test_advice_unsafe_to_replay() -> None:
error = Exception("cannot replay")
error.unsafe_to_replay = True # type: ignore[attr-defined]
advice = get_openai_retry_advice(_make_request(error))
assert advice is not None
assert advice.suggested is False
assert advice.replay_safety == "unsafe"
def test_advice_websocket_request_is_unsafe() -> None:
message = (
"The request may have been accepted, so the SDK will not automatically "
"retry this websocket request."
)
advice = get_openai_retry_advice(_make_request(Exception(message)))
assert advice is not None
assert advice.suggested is False
assert advice.replay_safety == "unsafe"
def test_advice_respects_x_should_retry_false() -> None:
error = _HeaderError("nope", headers={"x-should-retry": "false"})
advice = get_openai_retry_advice(_make_request(error))
assert advice is not None
assert advice.suggested is False
def test_advice_returns_retry_after_only_when_no_other_signal() -> None:
# A 400 with no x-should-retry header and no network/timeout signal would not
# normally retry, but a retry-after header still yields advice carrying the delay.
error = _HeaderError("slow down", headers={"retry-after": "2"})
advice = get_openai_retry_advice(_make_request(error))
assert advice is not None
assert advice.retry_after == 2.0
# This branch only conveys the server-provided delay; it does not assert a
# retry decision, so ``suggested`` keeps its unset default.
assert advice.suggested is None