Skip to content

feat(errors): expose Retry-After header on UsageLimitExceededError#166

Open
stihahi wants to merge 2 commits intotavily-ai:masterfrom
stihahi:feat/retry-after-on-usage-limit-error
Open

feat(errors): expose Retry-After header on UsageLimitExceededError#166
stihahi wants to merge 2 commits intotavily-ai:masterfrom
stihahi:feat/retry-after-on-usage-limit-error

Conversation

@stihahi
Copy link
Copy Markdown

@stihahi stihahi commented Apr 20, 2026

Summary

The Tavily REST API returns a Retry-After header on 429 Too Many Requests responses (docs),
but the Python SDK currently discards it and raises
UsageLimitExceededError(detail) with no way for callers to recover the
server's recommended wait. Applications implementing client-side
throttling (e.g. production workloads hitting the 1000 RPM tier) have to
fall back to a fixed exponential backoff and guess at the real cool-down.

This PR exposes the header value on the exception so callers can honor
the server's recommendation.

Changes

tavily/errors.py

  • Add UsageLimitExceededError.retry_after: Optional[float], defaulting
    to None for backward compatibility.

  • Add internal _parse_retry_after(headers) helper. Semantics follow
    urllib3.util.Retry.parse_retry_after — parse
    RFC 7231 §7.1.3:

    • non-negative decimal integer of seconds, e.g. "120" (clamped to ≥ 0)
    • HTTP-date, e.g. "Wed, 21 Oct 2015 07:28:00 GMT" (converted to
      seconds from now, past dates clamp to 0)

    Returns None when the header is absent, empty, fractional, NaN/inf,
    or otherwise unparseable.

  • Case-insensitive header name lookup is done explicitly, so callers
    passing a plain dict (not only requests/httpx header containers)
    work correctly.

tavily/tavily.py, tavily/async_tavily.py

  • At every 429 raise site (14 total: sync + async × search, extract,
    crawl, map, research request, research streaming), pass
    retry_after=parse_retry_after(response.headers).

tests/test_retry_after.py (new)

Covers:

  • sync + async: integer-seconds Retry-After is exposed on the exception
  • sync: HTTP-date Retry-After is parsed and exposed
  • sync: malformed Retry-After yields retry_after=None
  • sync + async: missing header yields retry_after=None
  • constructor: retry_after defaults to None and accepts explicit value
  • helper edge cases: fractional rejected, negative clamps to 0, NaN/inf
    rejected, case-insensitive header name, past HTTP-date clamps to 0,
    empty/whitespace-only value returns None

All existing tests still pass (88 passed including the 14 new cases).

Backward compatibility

Non-breaking.

  • UsageLimitExceededError(message) still works — retry_after is a
    default-None kwarg.
  • Callers doing except UsageLimitExceededError as e: str(e) are
    unaffected.
  • Opt-in for new consumers:
    except UsageLimitExceededError as e: sleep(e.retry_after or fallback).

Example

from tavily import TavilyClient
from tavily.errors import UsageLimitExceededError
import time

client = TavilyClient()
while True:
    try:
        results = client.search("quarterly reports")
        break
    except UsageLimitExceededError as e:
        wait = e.retry_after if e.retry_after is not None else 5.0
        time.sleep(wait)

Notes for reviewers

  • Kept the diff surgical. Each 429 site now reads
    retry_after=parse_retry_after(response.headers) — a follow-up could
    extract the full error-response-to-exception mapping into a shared
    helper, but that refactor is independent of this fix and intentionally
    deferred.
  • parse_retry_after accepts any mapping (works for both requests
    CaseInsensitiveDict and httpx Headers).
  • HTTP-date branch uses email.utils.parsedate_to_datetime (stdlib).
    Naive datetimes are assumed UTC.

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.
@stihahi stihahi marked this pull request as draft April 20, 2026 22:43
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 46de255. Configure here.

Comment thread tavily/errors.py
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
@stihahi stihahi marked this pull request as ready for review April 21, 2026 00:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant