deer-flow/backend/tests/test_image_search_errors.py
yi-coding ab43439a54
fix(image-search): distinguish DDG failures from empty results (#6680)
* fix(image-search): distinguish DDG failures from empty results

* fix(image-search): preserve ambiguous DDGS dispatcher failures

---------

Co-authored-by: xyi74976-del <290725798+xyi74976-del@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-21 22:06:16 +08:00

138 lines
5.3 KiB
Python

"""Image search outages must not be reported as an empty result set."""
import json
import sys
import threading
from concurrent.futures import wait as wait_for_futures
from types import SimpleNamespace
import ddgs
import ddgs.ddgs as ddgs_dispatch
import pytest
from ddgs.ddgs import DDGS
from ddgs.exceptions import DDGSException, RatelimitException, TimeoutException
from deerflow.community.image_search import tools
pytestmark = pytest.mark.asyncio
@pytest.fixture(autouse=True)
def configured_search(monkeypatch):
monkeypatch.setattr(tools, "get_app_config", lambda: SimpleNamespace(get_tool_config=lambda name: None))
@pytest.mark.parametrize("failure", ["rate-limit", "timeout", "constructor", "missing-dependency", "iteration"])
async def test_image_search_reports_execution_failure(monkeypatch, failure):
if failure == "missing-dependency":
monkeypatch.setitem(sys.modules, "ddgs", None)
expected = "not installed"
elif failure == "constructor":
def unavailable_client(**kwargs):
raise RuntimeError("search client unavailable")
monkeypatch.setattr(ddgs, "DDGS", unavailable_client)
expected = "search client unavailable"
else:
error = RatelimitException("HTTP 429") if failure == "rate-limit" else TimeoutException("search timed out")
def failing_search(self, query, **kwargs):
if failure == "iteration":
def incomplete_results():
yield {"title": "partial", "image": "https://example.com/image.jpg", "thumbnail": "https://example.com/thumb.jpg"}
raise error
return incomplete_results()
raise error
monkeypatch.setattr(DDGS, "images", failing_search)
expected = "429" if failure == "rate-limit" else "timed out"
output = json.loads(await tools.image_search_tool.ainvoke({"query": "雪山"}))
assert output["query"] == "雪山"
assert expected in output["error"]
assert output["error"] != "No images found"
assert "results" not in output
async def test_image_search_reports_configuration_failure(monkeypatch):
def invalid_config():
raise ValueError("invalid image search configuration")
monkeypatch.setattr(tools, "get_app_config", invalid_config)
output = json.loads(await tools.image_search_tool.ainvoke({"query": "a cat"}))
assert "invalid image search configuration" in output["error"]
assert output["query"] == "a cat"
async def test_image_search_preserves_genuinely_empty_results(monkeypatch):
def empty_search(self, query, **kwargs):
return []
monkeypatch.setattr(DDGS, "images", empty_search)
output = json.loads(await tools.image_search_tool.ainvoke({"query": "a cat"}))
assert output == {"error": "No images found", "query": "a cat"}
@pytest.mark.parametrize("backend_outcome", ["empty", "timeout", "late-timeout"])
async def test_image_search_does_not_mistake_sdk_dispatcher_failure_for_empty_results(monkeypatch, backend_outcome):
engine_started = threading.Event()
engine_outcome_observed = threading.Event()
pending_at_deadline = threading.Event()
deadline_expired = threading.Event()
def search(query, **kwargs):
engine_started.set()
if backend_outcome == "late-timeout":
assert deadline_expired.wait(timeout=5), "dispatcher must release the pending engine"
engine_outcome_observed.set()
if backend_outcome != "empty":
raise TimeoutException("image backend timed out")
return []
def expire_wait(futures, **kwargs):
try:
assert engine_started.wait(timeout=5), "engine must run before the wait deadline"
done, pending = wait_for_futures(futures, timeout=0.01, return_when=kwargs["return_when"])
assert pending, "regression must leave the failed request pending at the SDK deadline"
pending_at_deadline.set()
return done, pending
finally:
deadline_expired.set()
engine = SimpleNamespace(provider="offline", name="offline", search=search)
monkeypatch.setattr(DDGS, "_get_network_client", lambda self: None)
monkeypatch.setattr(DDGS, "_get_engines", lambda self, category, backend: [engine])
if backend_outcome == "late-timeout":
monkeypatch.setattr(ddgs_dispatch, "wait", expire_wait)
output = json.loads(await tools.image_search_tool.ainvoke({"query": "snow mountains"}))
assert engine_started.is_set()
assert engine_outcome_observed.is_set(), "the engine must reach its intended result, not fail a watchdog"
if backend_outcome == "late-timeout":
assert pending_at_deadline.is_set(), "the SDK must observe an unfinished request at the deadline"
assert output["query"] == "snow mountains"
assert output["error"].startswith("DuckDuckGo image search failed:")
assert output["error"] != "No images found"
assert "results" not in output
async def test_image_search_does_not_confuse_embedded_empty_result_text_with_success(monkeypatch):
def failed_search(self, query, **kwargs):
raise DDGSException("No results found for query: HTTP 429")
monkeypatch.setattr(DDGS, "images", failed_search)
output = json.loads(await tools.image_search_tool.ainvoke({"query": "a cat"}))
assert "429" in output["error"]
assert output["error"] != "No images found"