From 46de25540d456170b0c2ec6054289c568935bfa1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Satoshi=20Iwai=E2=9C=88=EF=B8=8F?= Date: Mon, 20 Apr 2026 15:35:23 -0700 Subject: [PATCH 1/2] feat(errors): expose Retry-After header on UsageLimitExceededError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Tavily API returns a Retry-After header on 429 Too Many Requests responses, but the SDK currently discards it. Callers implementing their own client-side throttling fall back to guessing a backoff instead of honoring the server's recommendation. This change: - Adds a retry_after: Optional[float] attribute to UsageLimitExceededError, defaulting to None for backward compatibility. - Adds parse_retry_after() in tavily.errors which accepts either form defined by RFC 7231 §7.1.3 (non-negative seconds or HTTP-date) and returns None when the header is absent or unparseable. - Populates retry_after at every 429 raise site in TavilyClient and AsyncTavilyClient (search, extract, crawl, map, research, research streaming). - Adds tests covering: integer seconds, HTTP-date, missing header, malformed value, sync + async paths, and the error constructor itself. No breaking changes: existing `except UsageLimitExceededError as e: ...` code keeps working; callers that want to honor the header now read e.retry_after. --- tavily/async_tavily.py | 16 +++---- tavily/errors.py | 51 +++++++++++++++++++++- tavily/tavily.py | 16 +++---- tests/test_retry_after.py | 91 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 157 insertions(+), 17 deletions(-) create mode 100644 tests/test_retry_after.py diff --git a/tavily/async_tavily.py b/tavily/async_tavily.py index dc896b8..f1e6e12 100644 --- a/tavily/async_tavily.py +++ b/tavily/async_tavily.py @@ -6,7 +6,7 @@ import httpx from .utils import get_max_items_from_list -from .errors import UsageLimitExceededError, InvalidAPIKeyError, MissingAPIKeyError, BadRequestError, ForbiddenError, TimeoutError +from .errors import UsageLimitExceededError, InvalidAPIKeyError, MissingAPIKeyError, BadRequestError, ForbiddenError, TimeoutError, parse_retry_after class AsyncTavilyClient: @@ -152,7 +152,7 @@ async def _search( pass if response.status_code == 429: - raise UsageLimitExceededError(detail) + raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) elif response.status_code in [403,432,433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -266,7 +266,7 @@ async def _extract( if response.status_code == 429: - raise UsageLimitExceededError(detail) + raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) elif response.status_code in [403,432,433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -375,7 +375,7 @@ async def _crawl(self, pass if response.status_code == 429: - raise UsageLimitExceededError(detail) + raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) elif response.status_code in [403,432,433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -485,7 +485,7 @@ async def _map(self, pass if response.status_code == 429: - raise UsageLimitExceededError(detail) + raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) elif response.status_code in [403,432,433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -679,7 +679,7 @@ async def stream_generator() -> AsyncGenerator[bytes, None]: error_text = "Unknown error" if response.status_code == 429: - raise UsageLimitExceededError(error_text) + raise UsageLimitExceededError(error_text, retry_after=parse_retry_after(response.headers)) elif response.status_code in [403,432,433]: raise ForbiddenError(error_text) elif response.status_code == 401: @@ -715,7 +715,7 @@ async def _make_request(): pass if response.status_code == 429: - raise UsageLimitExceededError(detail) + raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) elif response.status_code in [403,432,433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -794,7 +794,7 @@ async def get_research(self, pass if response.status_code == 429: - raise UsageLimitExceededError(detail) + raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) elif response.status_code in [403,432,433]: raise ForbiddenError(detail) elif response.status_code == 401: diff --git a/tavily/errors.py b/tavily/errors.py index 45fbbb4..4572e2d 100644 --- a/tavily/errors.py +++ b/tavily/errors.py @@ -1,6 +1,55 @@ +from datetime import datetime, timezone +from email.utils import parsedate_to_datetime +from typing import Mapping, Optional + + +def parse_retry_after(headers): + """Parse an HTTP ``Retry-After`` header value into seconds. + + Handles both forms defined by RFC 7231 §7.1.3: + + - a non-negative decimal integer of seconds, e.g. ``"120"`` + - an HTTP-date, e.g. ``"Wed, 21 Oct 2015 07:28:00 GMT"`` + + Returns ``None`` when the header is absent or cannot be parsed. + Accepts any mapping-like headers object (``requests`` / ``httpx``). + """ + if not headers: + return None + raw = headers.get("Retry-After") or headers.get("retry-after") + if raw is None: + return None + raw = raw.strip() + if not raw: + return None + try: + return float(raw) + except ValueError: + pass + try: + when = parsedate_to_datetime(raw) + except (TypeError, ValueError): + return None + if when is None: + return None + if when.tzinfo is None: + when = when.replace(tzinfo=timezone.utc) + delta = (when - datetime.now(timezone.utc)).total_seconds() + return max(delta, 0.0) + + class UsageLimitExceededError(Exception): - def __init__(self, message: str): + """Raised on HTTP 429 responses from the Tavily API. + + ``retry_after`` carries the server-recommended wait (seconds) parsed from + the ``Retry-After`` response header when present, so callers can honor the + server's backoff instead of guessing. ``None`` when the header is absent + or unparseable. + """ + + def __init__(self, message: str, retry_after: Optional[float] = None): super().__init__(message) + self.retry_after = retry_after class BadRequestError(Exception): diff --git a/tavily/tavily.py b/tavily/tavily.py index b059734..9966cfa 100644 --- a/tavily/tavily.py +++ b/tavily/tavily.py @@ -4,7 +4,7 @@ import warnings from typing import Literal, Sequence, Optional, List, Union, Generator from .utils import get_max_items_from_list -from .errors import UsageLimitExceededError, InvalidAPIKeyError, MissingAPIKeyError, BadRequestError, ForbiddenError, TimeoutError +from .errors import UsageLimitExceededError, InvalidAPIKeyError, MissingAPIKeyError, BadRequestError, ForbiddenError, TimeoutError, parse_retry_after class TavilyClient: """ @@ -130,7 +130,7 @@ def _search(self, pass if response.status_code == 429: - raise UsageLimitExceededError(detail) + raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) elif response.status_code in [403, 432, 433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -237,7 +237,7 @@ def _extract(self, pass if response.status_code == 429: - raise UsageLimitExceededError(detail) + raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) elif response.status_code in [403, 432, 433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -340,7 +340,7 @@ def _crawl(self, pass if response.status_code == 429: - raise UsageLimitExceededError(detail) + raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) elif response.status_code in [403, 432, 433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -448,7 +448,7 @@ def _map(self, pass if response.status_code == 429: - raise UsageLimitExceededError(detail) + raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) elif response.status_code in [403, 432, 433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -617,7 +617,7 @@ def _research(self, pass if response.status_code == 429: - raise UsageLimitExceededError(detail) + raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) elif response.status_code in [403, 432, 433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -656,7 +656,7 @@ def stream_generator() -> Generator[bytes, None, None]: pass if response.status_code == 429: - raise UsageLimitExceededError(detail) + raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) elif response.status_code in [403, 432, 433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -728,7 +728,7 @@ def get_research(self, pass if response.status_code == 429: - raise UsageLimitExceededError(detail) + raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) elif response.status_code in [403, 432, 433]: raise ForbiddenError(detail) elif response.status_code == 401: diff --git a/tests/test_retry_after.py b/tests/test_retry_after.py new file mode 100644 index 0000000..2819b13 --- /dev/null +++ b/tests/test_retry_after.py @@ -0,0 +1,91 @@ +"""Tests for Retry-After header propagation on 429 responses. + +Tavily API returns a ``Retry-After`` header when it rejects a request with +``429 Too Many Requests``. The SDK must expose that value on +``UsageLimitExceededError`` so callers can honor the server's recommended +wait instead of falling back to a fixed backoff. +""" + +import asyncio + +import pytest + +from tavily.errors import UsageLimitExceededError + + +RATE_LIMIT_BODY = {"detail": {"error": "rate limit exceeded"}} + + +def test_sync_429_exposes_retry_after_seconds(sync_interceptor, sync_client): + sync_interceptor.set_response(429, headers={"Retry-After": "7"}, json=RATE_LIMIT_BODY) + + with pytest.raises(UsageLimitExceededError) as exc_info: + sync_client.search("What is Tavily?") + + assert exc_info.value.retry_after == pytest.approx(7.0) + + +def test_sync_429_retry_after_absent_is_none(sync_interceptor, sync_client): + sync_interceptor.set_response(429, json=RATE_LIMIT_BODY) + + with pytest.raises(UsageLimitExceededError) as exc_info: + sync_client.search("What is Tavily?") + + assert exc_info.value.retry_after is None + + +def test_sync_429_retry_after_http_date(sync_interceptor, sync_client): + from email.utils import format_datetime + from datetime import datetime, timezone, timedelta + + future = datetime.now(timezone.utc) + timedelta(seconds=30) + sync_interceptor.set_response( + 429, + headers={"Retry-After": format_datetime(future, usegmt=True)}, + json=RATE_LIMIT_BODY, + ) + + with pytest.raises(UsageLimitExceededError) as exc_info: + sync_client.search("What is Tavily?") + + assert exc_info.value.retry_after is not None + assert 20 <= exc_info.value.retry_after <= 40 + + +def test_sync_429_retry_after_malformed_is_none(sync_interceptor, sync_client): + sync_interceptor.set_response( + 429, headers={"Retry-After": "not-a-number"}, json=RATE_LIMIT_BODY + ) + + with pytest.raises(UsageLimitExceededError) as exc_info: + sync_client.search("What is Tavily?") + + assert exc_info.value.retry_after is None + + +def test_async_429_exposes_retry_after_seconds(async_interceptor, async_client): + async_interceptor.set_response(429, headers={"Retry-After": "12"}, json=RATE_LIMIT_BODY) + + with pytest.raises(UsageLimitExceededError) as exc_info: + asyncio.run(async_client.search("What is Tavily?")) + + assert exc_info.value.retry_after == pytest.approx(12.0) + + +def test_async_429_retry_after_absent_is_none(async_interceptor, async_client): + async_interceptor.set_response(429, json=RATE_LIMIT_BODY) + + with pytest.raises(UsageLimitExceededError) as exc_info: + asyncio.run(async_client.search("What is Tavily?")) + + assert exc_info.value.retry_after is None + + +def test_usage_limit_error_default_retry_after_is_none(): + err = UsageLimitExceededError("boom") + assert err.retry_after is None + + +def test_usage_limit_error_accepts_retry_after(): + err = UsageLimitExceededError("boom", retry_after=3.5) + assert err.retry_after == 3.5 From 5413e11cec1f346e9748084b2fd0f519da5d939f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Satoshi=20Iwai=E2=9C=88=EF=B8=8F?= Date: Mon, 20 Apr 2026 15:50:40 -0700 Subject: [PATCH 2/2] review: tighten Retry-After parsing and add edge-case tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses upstream review feedback: - Reject fractional/non-finite seconds. RFC 7231 §7.1.3 defines the numeric form as a non-negative integer. Previous `float(raw)` accepted "7.5", "nan", "inf", "-10", which could land as retry_after on the exception and crash callers (time.sleep(nan) raises ValueError). Match urllib3.util.Retry.parse_retry_after semantics: parse int first, then HTTP-date, clamp negatives/past dates to 0. - Make header lookup explicitly case-insensitive by iterating items(), so the helper is correct for any Mapping (not only the case-insensitive containers from requests/httpx). - Rename parse_retry_after -> _parse_retry_after to signal internal-only API; not re-exported from tavily/__init__.py. - Add Mapping[str, str] type annotation on the headers parameter for consistency with the rest of errors.py. Test additions: - fractional seconds rejected - negative integer seconds clamp to 0 - NaN/inf rejected - case-insensitive header name ("retry-after", "RETRY-AFTER") - past HTTP-date clamps to 0 - empty/whitespace-only header values return None --- tavily/async_tavily.py | 16 ++++++++-------- tavily/errors.py | 25 ++++++++++++++++++++----- tavily/tavily.py | 16 ++++++++-------- tests/test_retry_after.py | 37 ++++++++++++++++++++++++++++++++++++- 4 files changed, 72 insertions(+), 22 deletions(-) diff --git a/tavily/async_tavily.py b/tavily/async_tavily.py index f1e6e12..17ca2e9 100644 --- a/tavily/async_tavily.py +++ b/tavily/async_tavily.py @@ -6,7 +6,7 @@ import httpx from .utils import get_max_items_from_list -from .errors import UsageLimitExceededError, InvalidAPIKeyError, MissingAPIKeyError, BadRequestError, ForbiddenError, TimeoutError, parse_retry_after +from .errors import UsageLimitExceededError, InvalidAPIKeyError, MissingAPIKeyError, BadRequestError, ForbiddenError, TimeoutError, _parse_retry_after class AsyncTavilyClient: @@ -152,7 +152,7 @@ async def _search( pass if response.status_code == 429: - raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) + raise UsageLimitExceededError(detail, retry_after=_parse_retry_after(response.headers)) elif response.status_code in [403,432,433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -266,7 +266,7 @@ async def _extract( if response.status_code == 429: - raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) + raise UsageLimitExceededError(detail, retry_after=_parse_retry_after(response.headers)) elif response.status_code in [403,432,433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -375,7 +375,7 @@ async def _crawl(self, pass if response.status_code == 429: - raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) + raise UsageLimitExceededError(detail, retry_after=_parse_retry_after(response.headers)) elif response.status_code in [403,432,433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -485,7 +485,7 @@ async def _map(self, pass if response.status_code == 429: - raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) + raise UsageLimitExceededError(detail, retry_after=_parse_retry_after(response.headers)) elif response.status_code in [403,432,433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -679,7 +679,7 @@ async def stream_generator() -> AsyncGenerator[bytes, None]: error_text = "Unknown error" if response.status_code == 429: - raise UsageLimitExceededError(error_text, retry_after=parse_retry_after(response.headers)) + raise UsageLimitExceededError(error_text, retry_after=_parse_retry_after(response.headers)) elif response.status_code in [403,432,433]: raise ForbiddenError(error_text) elif response.status_code == 401: @@ -715,7 +715,7 @@ async def _make_request(): pass if response.status_code == 429: - raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) + raise UsageLimitExceededError(detail, retry_after=_parse_retry_after(response.headers)) elif response.status_code in [403,432,433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -794,7 +794,7 @@ async def get_research(self, pass if response.status_code == 429: - raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) + raise UsageLimitExceededError(detail, retry_after=_parse_retry_after(response.headers)) elif response.status_code in [403,432,433]: raise ForbiddenError(detail) elif response.status_code == 401: diff --git a/tavily/errors.py b/tavily/errors.py index 4572e2d..0263576 100644 --- a/tavily/errors.py +++ b/tavily/errors.py @@ -3,7 +3,16 @@ from typing import Mapping, Optional -def parse_retry_after(headers): +def _find_header(headers: Mapping[str, str], name: str) -> Optional[str]: + """Return the value of ``name`` from ``headers`` with case-insensitive lookup.""" + target = name.lower() + for key, value in headers.items(): + if key.lower() == target: + return value + return None + + +def _parse_retry_after(headers: Optional[Mapping[str, str]]) -> Optional[float]: """Parse an HTTP ``Retry-After`` header value into seconds. Handles both forms defined by RFC 7231 §7.1.3: @@ -11,19 +20,25 @@ def parse_retry_after(headers): - a non-negative decimal integer of seconds, e.g. ``"120"`` - an HTTP-date, e.g. ``"Wed, 21 Oct 2015 07:28:00 GMT"`` - Returns ``None`` when the header is absent or cannot be parsed. - Accepts any mapping-like headers object (``requests`` / ``httpx``). + Semantics follow ``urllib3.util.Retry.parse_retry_after``: integer + seconds first, then HTTP-date. Negative or past values clamp to ``0.0``. + Returns ``None`` when the header is absent or cannot be parsed (including + non-integer numerics, ``NaN``/``inf``, and malformed dates). + + Accepts any mapping-like ``headers`` object. Case-insensitive header name + lookup is done explicitly so callers passing a plain ``dict`` (not only + ``requests``/``httpx`` header containers) work correctly. """ if not headers: return None - raw = headers.get("Retry-After") or headers.get("retry-after") + raw = _find_header(headers, "Retry-After") if raw is None: return None raw = raw.strip() if not raw: return None try: - return float(raw) + return max(float(int(raw)), 0.0) except ValueError: pass try: diff --git a/tavily/tavily.py b/tavily/tavily.py index 9966cfa..306d078 100644 --- a/tavily/tavily.py +++ b/tavily/tavily.py @@ -4,7 +4,7 @@ import warnings from typing import Literal, Sequence, Optional, List, Union, Generator from .utils import get_max_items_from_list -from .errors import UsageLimitExceededError, InvalidAPIKeyError, MissingAPIKeyError, BadRequestError, ForbiddenError, TimeoutError, parse_retry_after +from .errors import UsageLimitExceededError, InvalidAPIKeyError, MissingAPIKeyError, BadRequestError, ForbiddenError, TimeoutError, _parse_retry_after class TavilyClient: """ @@ -130,7 +130,7 @@ def _search(self, pass if response.status_code == 429: - raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) + raise UsageLimitExceededError(detail, retry_after=_parse_retry_after(response.headers)) elif response.status_code in [403, 432, 433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -237,7 +237,7 @@ def _extract(self, pass if response.status_code == 429: - raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) + raise UsageLimitExceededError(detail, retry_after=_parse_retry_after(response.headers)) elif response.status_code in [403, 432, 433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -340,7 +340,7 @@ def _crawl(self, pass if response.status_code == 429: - raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) + raise UsageLimitExceededError(detail, retry_after=_parse_retry_after(response.headers)) elif response.status_code in [403, 432, 433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -448,7 +448,7 @@ def _map(self, pass if response.status_code == 429: - raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) + raise UsageLimitExceededError(detail, retry_after=_parse_retry_after(response.headers)) elif response.status_code in [403, 432, 433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -617,7 +617,7 @@ def _research(self, pass if response.status_code == 429: - raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) + raise UsageLimitExceededError(detail, retry_after=_parse_retry_after(response.headers)) elif response.status_code in [403, 432, 433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -656,7 +656,7 @@ def stream_generator() -> Generator[bytes, None, None]: pass if response.status_code == 429: - raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) + raise UsageLimitExceededError(detail, retry_after=_parse_retry_after(response.headers)) elif response.status_code in [403, 432, 433]: raise ForbiddenError(detail) elif response.status_code == 401: @@ -728,7 +728,7 @@ def get_research(self, pass if response.status_code == 429: - raise UsageLimitExceededError(detail, retry_after=parse_retry_after(response.headers)) + raise UsageLimitExceededError(detail, retry_after=_parse_retry_after(response.headers)) elif response.status_code in [403, 432, 433]: raise ForbiddenError(detail) elif response.status_code == 401: diff --git a/tests/test_retry_after.py b/tests/test_retry_after.py index 2819b13..0e9e4cd 100644 --- a/tests/test_retry_after.py +++ b/tests/test_retry_after.py @@ -10,7 +10,7 @@ import pytest -from tavily.errors import UsageLimitExceededError +from tavily.errors import UsageLimitExceededError, _parse_retry_after RATE_LIMIT_BODY = {"detail": {"error": "rate limit exceeded"}} @@ -89,3 +89,38 @@ def test_usage_limit_error_default_retry_after_is_none(): def test_usage_limit_error_accepts_retry_after(): err = UsageLimitExceededError("boom", retry_after=3.5) assert err.retry_after == 3.5 + + +def test_parse_retry_after_rejects_fractional_seconds(): + # RFC 7231 §7.1.3 only defines non-negative integer seconds. + assert _parse_retry_after({"Retry-After": "7.5"}) is None + + +def test_parse_retry_after_clamps_negative_seconds(): + assert _parse_retry_after({"Retry-After": "-10"}) == 0.0 + + +def test_parse_retry_after_rejects_nan_and_inf(): + assert _parse_retry_after({"Retry-After": "nan"}) is None + assert _parse_retry_after({"Retry-After": "inf"}) is None + + +def test_parse_retry_after_case_insensitive_lookup(): + assert _parse_retry_after({"retry-after": "5"}) == 5.0 + assert _parse_retry_after({"RETRY-AFTER": "5"}) == 5.0 + + +def test_parse_retry_after_past_http_date_clamps_to_zero(): + from email.utils import format_datetime + from datetime import datetime, timezone, timedelta + + past = datetime.now(timezone.utc) - timedelta(seconds=60) + result = _parse_retry_after({"Retry-After": format_datetime(past, usegmt=True)}) + assert result == 0.0 + + +def test_parse_retry_after_empty_and_none(): + assert _parse_retry_after(None) is None + assert _parse_retry_after({}) is None + assert _parse_retry_after({"Retry-After": ""}) is None + assert _parse_retry_after({"Retry-After": " "}) is None