179 lines
6.2 KiB
Python
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
|