feat(serper): add opt-in bounded transient retries (#6456)

* feat(serper): add opt-in bounded transient retries

Signed-off-by: 97three <2212371308@qq.com>

* fix(serper): validate HTTP retry dates precisely

Signed-off-by: 97three <2212371308@qq.com>

* fix(serper): address retry review feedback

---------

Signed-off-by: 97three <2212371308@qq.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
creed 2026-10-10 10:10:28 +08:00 committed by GitHub
parent 2d55c75870
commit be62fff070
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 508 additions and 7 deletions

View File

@ -1472,6 +1472,13 @@ global URL-access policy. The model arguments and image search are unchanged.
See [Serper configuration](backend/docs/CONFIGURATION.md#serper-source-filters)
for validation and query-length limits.
Serper web and image search support opt-in transient recovery with
`max_retries: 1` (default `0`, maximum `3`) in each tool's configuration.
`retry_budget_seconds` bounds retry scheduling, not active synchronous requests.
Budget exhaustion is logged separately while the tool returns the last error.
Extra requests may consume quota or incur cost. See
[Serper retries](backend/docs/CONFIGURATION.md#serper-retries).
When using Tavily for `web_fetch`, extracted pages without a title use their URL
as the heading; their content remains available to the agent.
Chat tool-step titles accept leading blank lines and up to three spaces before

View File

@ -734,6 +734,46 @@ Serper `web_search` also accepts the optional model argument
to Serper. Omitting `time_range` or passing `null` omits the recency constraint from the
search request. This option does not change Serper `image_search`.
#### Serper retries
Both Serper `web_search` and `image_search` accept these operator-only settings
in their respective `tools` entries (the model-facing arguments are unchanged):
```yaml
max_retries: 0 # Default off; integer 0–3 extra requests
retry_budget_seconds: 30 # Default 30; finite number >0 and <=300
```
Set `max_retries: 1` to enable recovery. Booleans, strings, nulls, out-of-range
values, and non-finite budgets return a structured configuration error before
HTTP. The three-retry cap limits additional quota/cost; the 300-second budget
cap limits scheduling waits. No billing or idempotency guarantee is claimed.
Only connection establishment failures (`ConnectError`/`ConnectTimeout`) and
HTTP 502/503/504 are retried. HTTP 429 requires a valid `Retry-After` hint.
Authentication errors, other statuses, read/write timeouts, malformed successful
JSON and tool/result-processing failures are never retried.
Backoff uses equal jitter: 0.25–0.5, 0.5–1, then 1–2 seconds. Valid nonnegative
integer seconds or HTTP-date `Retry-After` hints on 429/503 set a minimum wait
(past dates mean zero). HTTP dates include asctime and RFC 850 forms; RFC 850
two-digit years resolve to the most recent matching year no more than 50 years
in the future. Invalid hints leave 503 on local backoff;
429 stays terminal. A hint or backoff that reaches/exceeds the remaining budget
returns the last error without another request or a shortened wait.
Both budget checks log `Serper retry time budget exhausted` at warning level,
distinguishing budget stops from exhausting all configured attempts without
changing the returned error.
One monotonic scheduling deadline starts before client creation, counting elapsed
requests and waits; it is checked before waiting and again before a retry.
It does **not** interrupt active synchronous HTTP requests, response streaming,
or worker threads. HTTPX's unchanged 30-second timeout applies per network phase,
so this is **not** a hard end-to-end wall-clock deadline. The initial request is
always allowed. Payload, API key, domain restrictions and time range stay identical
across attempts; errors still report the cleaned original query. No refill,
cache, global limiter or model-level retry is added.
#### Serper endpoints
To use a Serper-compatible provider for web and image search, set
@ -821,7 +861,7 @@ including on provider errors.
`time_range` still maps to `tbs`, and `max_results` caps the filtered results.
`total_results` reports the actual remaining count (zero with `results: []`
when none survive). One request is made: no refill or relaxed-filter retries.
when none survive). No refill or relaxed-filter requests are made.
These settings do not affect `image_search` or the model-facing tool schema.
Source selection is neither a factuality guarantee nor a global URL-access
policy for fetch tools, browsers, or redirects.

View File

@ -21,7 +21,7 @@ Read each tool's config once and pass captured extras to key resolution,
including an empty mapping when config is absent, to keep endpoint/key paired
through hot reload. Settings stay operator-only. Send keys
only via `X-API-KEY`, and preserve result URL validation and model arguments.
Future retries must reuse the resolved endpoint across attempts. Debug endpoint
Retries must reuse the resolved endpoint across attempts. Debug endpoint
diagnostics omit URL credentials, query and fragment; result-URL guards do not
restrict the operator's API host.
@ -32,3 +32,16 @@ handlers can redact paths. Assert full endpoint routing on the captured HTTP
request, and verify diagnostic host/port and credential omission in both modes.
Mock HTTP; live Serper semantics remain unverified. See
`backend/docs/CONFIGURATION.md#serper-source-filters` for the operator contract.
`_serper_post` owns default-off physical retries (0–3) and one monotonic
scheduling budget (>0–300s), not cancellation of synchronous I/O. Keep config
validation before transport and retry only connect failures, 502/503/504 and
hinted 429. Provider-local hint parsing avoids coupling to Jina. Whole-tool
extension hooks cannot isolate this boundary. Test with real MockTransport
through both tools in `backend/tests/test_serper_retries.py`.
Endpoint/retry regressions change configuration and environment during backoff;
every attempt must keep the original provider URL, API key and request payload.
Budget aborts before and after backoff log a warning identifying the stage,
without query/key values, and preserve the last structured error. Date tests
pin asctime UTC anchoring (under a non-UTC host timezone where supported) and
the RFC 850 50-year cutoff down to the second.

View File

@ -7,9 +7,14 @@ API. An API key is required. Sign up at https://serper.dev to get one.
import json
import logging
import math
import os
import random
import re
import time
from collections.abc import Mapping
from datetime import UTC, datetime
from email.utils import parsedate_to_datetime
from ipaddress import IPv4Address, ip_address
from urllib.parse import urlparse
@ -249,9 +254,73 @@ def _safe_public_url(value: object) -> str:
return url if ip.is_global else ""
def _serper_post(endpoint: str, api_key: str, query: str, max_results: int, *, time_range: SearchTimeRange | None = None, base_url: object | None = None) -> tuple[dict | None, str | None]:
def _retry_options(config) -> dict:
extra = config.model_extra if config is not None else {}
return {name: extra[name] for name in ("max_retries", "retry_budget_seconds") if name in extra}
_DAY = r"(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun)"
_MONTH = r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)"
_CLOCK = r"[0-9]{2}:[0-9]{2}:[0-9]{2}"
_HTTP_DATE = re.compile(
rf"(?:{_DAY}, [0-9]{{2}} {_MONTH} [0-9]{{4}} {_CLOCK} GMT"
rf"|(?:Monday|Tuesday|Wednesday|Thursday|Friday|Saturday|Sunday), [0-9]{{2}}-{_MONTH}-[0-9]{{2}} {_CLOCK} GMT"
rf"|{_DAY} {_MONTH} (?:[0-9]{{2}}| [0-9]) {_CLOCK} [0-9]{{4}})"
)
def _retry_after(response: httpx.Response) -> float | None:
"""Return a server floor, or None for an invalid HTTP Retry-After value."""
value = response.headers.get("Retry-After")
if value is None:
return None
value = value.strip(" \t")
if value and value.isascii() and value.isdecimal():
digits = value.lstrip("0") or "0"
# Bound integer conversion work. Infinity is a valid, unfit floor, not
# a parsing failure that could cause an early fallback retry.
if len(digits) > 309:
return math.inf
seconds = int(digits)
try:
floor = float(seconds)
except OverflowError:
return math.inf
return math.nextafter(floor, math.inf) if floor < seconds else floor
if not _HTTP_DATE.fullmatch(value):
return None
try:
date = parsedate_to_datetime(value).replace(tzinfo=UTC)
now = time.time()
if "-" in value:
# RFC 850 two-digit years: choose the most recent matching year
# no more than 50 years in the future (RFC 9110 section 5.6.7).
current = datetime.fromtimestamp(now, UTC)
year = (current.year + 50) // 100 * 100 + date.year % 100
if (year, date.month, date.day, date.hour, date.minute, date.second) > (current.year + 50, current.month, current.day, current.hour, current.minute, current.second):
year -= 100
date = date.replace(year=year)
return max(0.0, date.timestamp() - now)
except (ValueError, OverflowError):
return None
def _serper_post(
endpoint: str,
api_key: str,
query: str,
max_results: int,
*,
time_range: SearchTimeRange | None = None,
base_url: object | None = None,
max_retries: int = 0,
retry_budget_seconds: float = 30,
) -> tuple[dict | None, str | None]:
"""Send a POST request to a Serper endpoint.
Retries share a scheduling deadline, not a hard synchronous I/O deadline.
HTTPX timeouts remain per phase; an active request cannot be cancelled here.
``query`` is expected to already be normalized via :func:`_clean_query`.
A non-blank string ``base_url`` overrides ``SERPER_BASE_URL``; other raw
tool-config values fall back to the environment and then the default endpoint.
@ -271,6 +340,11 @@ def _serper_post(endpoint: str, api_key: str, query: str, max_results: int, *, t
payload["tbs"] = _SERPER_TBS_BY_TIME_RANGE[time_range]
try:
if isinstance(max_retries, bool) or not isinstance(max_retries, int) or not 0 <= max_retries <= 3:
raise ValueError("max_retries must be an integer between 0 and 3")
if isinstance(retry_budget_seconds, bool) or not isinstance(retry_budget_seconds, (int, float)) or not 0 < retry_budget_seconds <= 300 or not math.isfinite(retry_budget_seconds):
raise ValueError("retry_budget_seconds must be a finite number greater than 0 and at most 300")
deadline = time.monotonic() + retry_budget_seconds
# Resolve once before transport setup so retries can reuse this endpoint.
if not isinstance(base_url, str) or not base_url.strip():
base_url = os.getenv("SERPER_BASE_URL") or ""
@ -294,8 +368,31 @@ def _serper_post(endpoint: str, api_key: str, query: str, max_results: int, *, t
parsed_endpoint.path,
)
with httpx.Client(timeout=30) as client:
response = client.post(endpoint, headers=headers, json=payload)
response.raise_for_status()
for attempt in range(max_retries + 1):
try:
response = client.post(endpoint, headers=headers, json=payload)
response.raise_for_status()
break
except (httpx.ConnectError, httpx.ConnectTimeout, httpx.HTTPStatusError) as exc:
if attempt >= max_retries:
raise
hint = None
if isinstance(exc, httpx.HTTPStatusError):
status = exc.response.status_code
if status in (429, 503):
hint = _retry_after(exc.response)
if status not in (502, 503, 504) and not (status == 429 and hint is not None):
raise
# Equal jitter retains exponential pacing even for Retry-After: 0.
backoff = min(0.5 * 2**attempt, 2.0)
delay = max(random.uniform(backoff / 2, backoff), hint or 0.0)
if delay >= deadline - time.monotonic():
logger.warning("Serper retry time budget exhausted before backoff after %d attempt(s)", attempt + 1)
raise
time.sleep(delay)
if time.monotonic() >= deadline:
logger.warning("Serper retry time budget exhausted after backoff after %d attempt(s)", attempt + 1)
raise
data = response.json()
if not isinstance(data, dict):
logger.error("Serper returned an unexpected payload type: %s", type(data).__name__)
@ -342,7 +439,7 @@ def web_search_tool(query: str, max_results: int = 5, time_range: SearchTimeRang
return _missing_key_error(query, "web_search")
base_url = extra.get("base_url")
data, error_json = _serper_post(_SERPER_SEARCH_ENDPOINT, api_key, search_query, max_results, time_range=time_range, base_url=base_url)
data, error_json = _serper_post(_SERPER_SEARCH_ENDPOINT, api_key, search_query, max_results, time_range=time_range, base_url=base_url, **_retry_options(config))
if error_json is not None:
error = json.loads(error_json)
error["query"] = query
@ -399,7 +496,7 @@ def image_search_tool(query: str, max_results: int = 5) -> str:
return _missing_key_error(query, "image_search")
base_url = extra.get("base_url")
data, error_json = _serper_post(_SERPER_IMAGES_ENDPOINT, api_key, query, max_results, base_url=base_url)
data, error_json = _serper_post(_SERPER_IMAGES_ENDPOINT, api_key, query, max_results, base_url=base_url, **_retry_options(config))
if error_json is not None:
return error_json

View File

@ -0,0 +1,340 @@
"""Offline recovery through production tools and real HTTPX transports."""
import json
import logging
import time
from datetime import UTC, datetime
from types import SimpleNamespace
import httpx
import pytest
from deerflow.community.serper import tools
@pytest.fixture(params=["web_search", "image_search"])
def search(request, monkeypatch):
name = request.param
clients = []
requests = []
transports = []
clock = SimpleNamespace(now=0.0, wall=1700000000.0, waits=[], elapsed=0.0, oversleep=0.0, jitter=1.0, ranges=[])
real_client = httpx.Client
def sleep(delay):
clock.waits.append(delay)
clock.now += delay + clock.oversleep
monkeypatch.setattr(tools, "time", SimpleNamespace(monotonic=lambda: clock.now, time=lambda: clock.wall, sleep=sleep), raising=False)
def uniform(low, high):
clock.ranges.append((low, high))
return low + (high - low) * clock.jitter
monkeypatch.setattr(tools, "random", SimpleNamespace(uniform=uniform), raising=False)
class Transport(httpx.MockTransport):
closed = False
def close(self):
self.closed = True
super().close()
def run(outcomes, options=None, configured=True, direct=False):
extra = {"api_key": "dummy-key", **(options or {})}
monkeypatch.setenv("SERPER_API_KEY", "dummy-key")
monkeypatch.setattr(tools, "get_app_config", lambda: SimpleNamespace(get_tool_config=lambda _: SimpleNamespace(model_extra=extra) if configured else None))
def handle(req):
requests.append(req)
clock.now += clock.elapsed
outcome = outcomes[min(len(requests) - 1, len(outcomes) - 1)]
if isinstance(outcome, Exception):
raise outcome
if isinstance(outcome, tuple):
return httpx.Response(outcome[0], headers={"Retry-After": outcome[1]})
if outcome == 200:
return httpx.Response(200, json={"organic": [{"link": "https://example.com/a"}, {"link": "https://ads.example.com/a"}], "images": [{"imageUrl": "https://example.com/a"}]})
if outcome == "bad-json":
return httpx.Response(200, text="broken")
return httpx.Response(outcome)
def client(**kwargs):
transport = Transport(handle)
transports.append(transport)
result = real_client(transport=transport, **kwargs)
clients.append(result)
return result
monkeypatch.setattr(tools.httpx, "Client", client)
if direct:
data, error = tools._serper_post("https://google.serper.dev/search", "dummy-key", "news", 5, **(options or {}))
result = json.loads(error) if error else data
else:
arguments = {"query": " news "}
if name == "web_search":
arguments["time_range"] = "week"
result = json.loads(getattr(tools, name + "_tool").invoke(arguments))
assert all(client.is_closed for client in clients)
assert all(transport.closed for transport in transports)
return result
run.requests = requests
run.clients = clients
run.clock = clock
run.name = name
return run
@pytest.fixture
def non_utc_timezone(monkeypatch):
if not hasattr(time, "tzset"):
yield
return
try:
with monkeypatch.context() as local_timezone:
local_timezone.setenv("TZ", "GMT-8")
time.tzset()
yield
finally:
time.tzset()
@pytest.mark.parametrize("outcome", [502, 503, 504, (429, "0"), httpx.ConnectError("connect"), httpx.ConnectTimeout("connect")])
def test_transient_recovers(search, outcome):
result = search([outcome, 200], {"max_retries": 1})
assert "error" not in result
assert len(search.requests) == 2
@pytest.mark.parametrize("configured", [True, False])
@pytest.mark.parametrize("outcome", [503, (429, "0"), httpx.ConnectTimeout("connect")])
def test_default_single_attempt(search, configured, outcome):
assert "error" in search([outcome, 200], configured=configured)
assert len(search.requests) == 1
assert search.clock.waits == []
def test_helper_recovery(search):
assert "organic" in search([503, 200], {"max_retries": 1}, direct=True)
assert len(search.requests) == 2
def test_exhaustion(search, caplog):
assert search([503], {"max_retries": 3}) == {"query": "news", "error": "Serper API error: HTTP 503"}
assert len(search.requests) == 4
assert search.clock.waits == [0.5, 1.0, 2.0]
assert "retry time budget exhausted" not in caplog.text
@pytest.mark.parametrize("outcome", [400, 401, 403, 429, 500, "bad-json", httpx.ReadTimeout("read"), httpx.WriteTimeout("write"), httpx.ReadError("read"), httpx.PoolTimeout("pool")])
def test_terminal(search, outcome):
assert "error" in search([outcome, 200], {"max_retries": 3})
assert len(search.requests) == 1
@pytest.mark.parametrize("field,value", [("max_retries", v) for v in [-1, 4, True, 1.0, "1", None]] + [("retry_budget_seconds", v) for v in [0, -1, True, "1", None, float("nan"), float("inf"), 301]])
def test_invalid_configuration(search, field, value):
assert field in search([200], {field: value})["error"]
assert not search.requests
assert not search.clients
@pytest.mark.parametrize("status", [429, 503])
@pytest.mark.parametrize("hint,delay", [("0", 0.5), ("2", 2.0), ("Tue, 14 Nov 2023 22:13:22 GMT", 2.0), ("Tue, 14 Nov 2023 22:13:19 GMT", 0.5)])
def test_valid_hints(search, status, hint, delay):
assert "error" not in search([(status, hint), 200], {"max_retries": 1})
assert search.clock.waits == [delay]
@pytest.mark.parametrize("hint", ["", "-1", "1.5", "NaN", "inf", "tomorrow", "1, 2"])
@pytest.mark.parametrize("status", [429, 503])
def test_invalid_hints(search, hint, status):
result = search([(status, hint), 200], {"max_retries": 1})
assert len(search.requests) == (1 if status == 429 else 2)
assert ("error" in result) == (status == 429)
@pytest.mark.parametrize("status", [429, 503])
@pytest.mark.parametrize("hint", ["31", "9" * 5000, "Tue, 14 Nov 2023 22:14:00 GMT"])
def test_hint_exceeds_budget(search, status, hint):
assert "error" in search([(status, hint), 200], {"max_retries": 1})
assert len(search.requests) == 1
assert search.clock.waits == []
@pytest.mark.parametrize("elapsed,budget,attempts,waits", [(0, 1.5, 2, [0.5]), (1, 2, 2, [0.5]), (2, 1, 1, [])])
def test_single_deadline(search, elapsed, budget, attempts, waits):
search.clock.elapsed = elapsed
assert "error" in search([503], {"max_retries": 3, "retry_budget_seconds": budget})
assert len(search.requests) == attempts
assert search.clock.waits == waits
def test_oversleep_prevents_request(search):
search.clock.oversleep = 2
assert "error" in search([503, 200], {"max_retries": 1, "retry_budget_seconds": 1})
assert len(search.requests) == 1
@pytest.mark.parametrize("outcome", [503, (429, "0"), httpx.ConnectTimeout("connect")])
@pytest.mark.parametrize("budget,oversleep,stage,waits", [(0.5, 0, "before backoff", []), (1, 2, "after backoff", [0.5])])
def test_budget_exhaustion_diagnostic(search, caplog, outcome, budget, oversleep, stage, waits):
search.clock.oversleep = oversleep
result = search([outcome, 200], {"max_retries": 1, "retry_budget_seconds": budget})
status = outcome[0] if isinstance(outcome, tuple) else outcome
last_error = "connect" if isinstance(outcome, Exception) else f"Serper API error: HTTP {status}"
assert result == {"error": last_error, "query": "news"}
assert len(search.requests) == 1
assert search.clock.waits == waits
diagnostics = [record for record in caplog.records if record.name == tools.logger.name and "retry time budget exhausted" in record.getMessage()]
assert len(diagnostics) == 1
assert diagnostics[0].levelno == logging.WARNING
assert stage in diagnostics[0].getMessage()
assert "dummy-key" not in diagnostics[0].getMessage()
assert "news" not in diagnostics[0].getMessage()
def test_preserves_request_and_filters(search):
result = search([503, 200], {"max_retries": 1, "max_results": 1, "include_domains": ["example.com"], "exclude_domains": ["ads.example.com"]})
assert result["query"] == "news"
assert result["total_results"] == 1
first, second = search.requests
assert first.content == second.content
assert first.headers == second.headers
assert first.headers["X-API-KEY"] == "dummy-key"
expected = {"q": "news", "num": 1}
if search.name == "web_search":
expected.update(q="(news) (site:example.com) -site:ads.example.com", tbs="qdr:w")
assert result["results"][0]["url"] == "https://example.com/a"
assert json.loads(first.content) == expected
@pytest.mark.parametrize("endpoint_source", ["tool-config", "environment"])
def test_retry_keeps_configured_endpoint_and_key_through_reload(search, monkeypatch, endpoint_source):
"""A retry must not switch providers when config or environment changes."""
options = {"max_retries": 1}
if endpoint_source == "tool-config":
options["base_url"] = " https://first.example/api/// "
monkeypatch.setenv("SERPER_BASE_URL", "https://fallback.example")
else:
monkeypatch.setenv("SERPER_BASE_URL", " https://first.example/api/// ")
original_sleep = tools.time.sleep
def reload_during_backoff(delay):
original_sleep(delay)
monkeypatch.setenv("SERPER_BASE_URL", "https://second.example/api")
monkeypatch.setenv("SERPER_API_KEY", "second-provider-key")
def reloaded_config():
pytest.fail("Retries must use the captured tool configuration")
monkeypatch.setattr(tools, "get_app_config", reloaded_config)
monkeypatch.setattr(tools.time, "sleep", reload_during_backoff)
result = search([503, 200], options)
assert "error" not in result
assert len(search.requests) == 2
route = "search" if search.name == "web_search" else "images"
assert {str(request.url) for request in search.requests} == {f"https://first.example/api/{route}"}
assert {request.headers["X-API-KEY"] for request in search.requests} == {"dummy-key"}
assert search.requests[0].content == search.requests[1].content
def test_filtered_error_keeps_original_query(search):
result = search([503], {"max_retries": 1, "include_domains": ["example.com"]})
assert result == {"query": "news", "error": "Serper API error: HTTP 503"}
assert len(search.requests) == 2
@pytest.mark.parametrize("options", [{"max_retries": 0}, {"retry_budget_seconds": 1}])
def test_budget_does_not_enable_retries(search, options):
assert "error" in search([503, 200], options)
assert len(search.requests) == 1
def test_jitter_lower_bound(search):
search.clock.jitter = 0
search([503], {"max_retries": 3})
assert search.clock.waits == [0.25, 0.5, 1.0]
assert search.clock.ranges == [(0.25, 0.5), (0.5, 1), (1, 2)]
def test_direct_invalid_options(search):
assert "max_retries" in search([200], {"max_retries": True}, direct=True)["error"]
assert not search.clients
def test_tool_schemas_unchanged():
assert set(tools.web_search_tool.args_schema.model_json_schema()["properties"]) == {"query", "max_results", "time_range"}
assert set(tools.image_search_tool.args_schema.model_json_schema()["properties"]) == {"query", "max_results"}
@pytest.mark.parametrize("outcome", [httpx.ConnectError("connect"), httpx.ConnectTimeout("connect")])
def test_connection_exhaustion(search, outcome):
assert search([outcome], {"max_retries": 1}) == {"error": "connect", "query": "news"}
assert len(search.requests) == 2
def test_last_status_on_budget_exhaustion(search):
result = search([503, (429, "30"), 200], {"max_retries": 3})
assert result == {"error": "Serper API error: HTTP 429", "query": "news"}
assert len(search.requests) == 2
assert search.clock.waits == [0.5]
@pytest.mark.parametrize("hint", ["Sun Nov 6 08:49:37 1994", "Sunday, 06-Nov-94 08:49:37 GMT"])
def test_legacy_http_date_is_valid(search, hint):
result = search([(429, hint), 200], {"max_retries": 1})
assert "error" not in result
assert len(search.requests) == 2
@pytest.mark.parametrize("status", [429, 503])
@pytest.mark.parametrize(
"current,hint,delay",
[
(datetime(2023, 11, 14, 22, 13, 20, tzinfo=UTC), "Tue Nov 14 22:13:22 2023", 2.0),
(datetime(2023, 11, 14, 22, 13, 20, tzinfo=UTC), "Tue Nov 14 22:13:19 2023", 0.5),
(datetime(2023, 11, 5, 8, 49, 37, tzinfo=UTC), "Sun Nov 5 08:49:39 2023", 2.0),
(datetime(2023, 11, 5, 8, 49, 37, tzinfo=UTC), "Sun Nov 5 08:49:36 2023", 0.5),
],
)
def test_asctime_hint_uses_utc_and_server_floor(search, non_utc_timezone, status, current, hint, delay):
search.clock.wall = current.timestamp()
assert "error" not in search([(status, hint), 200], {"max_retries": 1})
assert len(search.requests) == 2
assert search.clock.waits == [delay]
@pytest.mark.parametrize("status", [429, 503])
@pytest.mark.parametrize(
"hint,resolved",
[
("Tuesday, 14-Nov-23 22:13:22 GMT", datetime(2023, 11, 14, 22, 13, 22, tzinfo=UTC)),
("Saturday, 14-Nov-26 22:13:20 GMT", datetime(2026, 11, 14, 22, 13, 20, tzinfo=UTC)),
("Sunday, 14-Nov-76 22:13:20 GMT", datetime(1976, 11, 14, 22, 13, 20, tzinfo=UTC)),
("Tuesday, 14-Nov-73 22:13:20 GMT", datetime(2073, 11, 14, 22, 13, 20, tzinfo=UTC)),
("Wednesday, 14-Nov-73 22:13:21 GMT", datetime(1973, 11, 14, 22, 13, 21, tzinfo=UTC)),
],
)
def test_rfc850_hint_year_window_and_scheduling(search, status, hint, resolved):
floor = max(0.0, resolved.timestamp() - search.clock.wall)
assert tools._retry_after(httpx.Response(status, headers={"Retry-After": hint})) == floor
result = search([(status, hint), 200], {"max_retries": 1})
if floor >= 30:
assert result == {"error": f"Serper API error: HTTP {status}", "query": "news"}
assert len(search.requests) == 1
assert search.clock.waits == []
else:
assert "error" not in result
assert len(search.requests) == 2
assert search.clock.waits == [max(0.5, floor)]
@pytest.mark.parametrize("hint", ["Wed, 21 Oct 2015 07:28:00 +0100", "Wed, 21 Oct 2015 07:28:00 UTC", "Wed, 21 Oct 2015 07:28 GMT"])
def test_email_dates_do_not_enable_429_retry(search, hint):
result = search([(429, hint), 200], {"max_retries": 1})
assert "error" in result
assert len(search.requests) == 1

View File

@ -935,6 +935,8 @@ tools:
# group: web
# use: deerflow.community.serper.tools:web_search_tool
# max_results: 5 # capped at 10 by the Serper provider
# max_retries: 0 # Opt in with 1-3 extra requests; may incur cost
# retry_budget_seconds: 30 # >0 to 300; scheduling budget, not an I/O deadline
# # base_url: https://google.serper.dev # Overrides SERPER_BASE_URL; omit /search
# include_domains: [] # Optional domain-only lists, at most 10 entries each
# exclude_domains: [] # Exclusion wins; see backend/docs/CONFIGURATION.md
@ -1278,6 +1280,8 @@ tools:
# group: web
# use: deerflow.community.serper.tools:image_search_tool
# max_results: 5 # capped at 10 by the Serper provider
# max_retries: 0 # Opt in with 1-3 extra requests; may incur cost
# retry_budget_seconds: 30 # >0 to 300; scheduling budget, not an I/O deadline
# # base_url: https://google.serper.dev # Overrides SERPER_BASE_URL; omit /images
# # api_key: $SERPER_API_KEY # Optional explicit env-var reference