deer-flow/backend/tests/test_serper_retries.py
creed be62fff070
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>
2026-10-10 10:10:28 +08:00

341 lines
14 KiB
Python

"""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