chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user