deer-flow/backend/tests/blocking_io/test_youcom_search_async.py
treasuresyq 6caa5addf3
feat(community): migrate You.com tools to httpx.AsyncClient (#7186)
* feat(community): migrate You.com tools to async HTTP

The You.com provider sent its search request through a sync httpx.Client. A
sync LangChain tool invoked through ainvoke runs on the default executor, so
the request held one of that pool's threads for the whole network round-trip,
and several providers queried in parallel queued behind each other.

Make the tool a coroutine over httpx.AsyncClient, resolve the API key and
the configured max_results in a single config read, and move both disk reads
off the event loop: the tool config, and the User-Agent, which reads the
installed package metadata the first time it is needed.

* refactor(community): drop the dead You.com API key wrapper

The async migration resolved the key and max_results through
_resolve_tool_settings, which left _get_api_key with no production
caller: only TestGetApiKey still exercised it. Keeping a second seam
around the same config read lets it drift from the real call path, so
drop the wrapper and point the tests at _resolve_tool_settings.

Also keep the hot path off the executor: _user_agent is cached, so only
the first call reads the installed package metadata. Later calls are
in-memory hits and now skip the to_thread round-trip.

Finally, record which httpx client factory the provider reaches for in
the runtime anchor. MockTransport runs its handler on the calling thread
without real blocking I/O, so a provider that stayed async but sent the
request through a sync client would satisfy the thread assertion; the
recorded factory is what catches that partial regression.

* test(community): align You.com URL safety stub with async tool

The URL safety matrix still patched the removed _get_api_key seam and invoked the You.com tool synchronously. Mock the awaited search with AsyncMock and call the tool through ainvoke so URL screening remains covered after the async migration.

---------

Co-authored-by: treasuresyq <309743290+treasuresyq@users.noreply.github.com>
Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-23 17:59:14 +08:00

160 lines
6.4 KiB
Python

"""The You.com community tool must not occupy an executor thread or block the loop.
The You.com provider resolved its config and sent its search request through
a synchronous ``httpx.Client``. A sync LangChain tool invoked through
``ainvoke`` runs on the default executor, so the request held one of that
pool's threads for the whole network round-trip: with several providers
queried in parallel the default pool ran out of threads and the tool calls
queued behind each other.
The probes below assert the whole fix:
* the request is awaited on the event loop thread instead of an executor
thread, so no pool thread is held for the round-trip;
* the config lookup, which reads the config file, still runs off the loop.
The tool body is now a coroutine that executes on the loop, so a file read
left in it would block the loop; the strict blocking-I/O gate fails if the
lookup regresses onto the loop.
* the request goes out through the async client. A `MockTransport` runs its
handler on the calling thread and performs no real blocking I/O, so a
provider that stayed async but sent the request through a sync
`httpx.Client` would satisfy the thread assertion above; the recorded
client factory is what catches that partial regression.
"""
import asyncio
import threading
from pathlib import Path
from types import SimpleNamespace
import httpx
import pytest
from deerflow.community.youcom import tools as youcom_tools
pytestmark = pytest.mark.asyncio
def _patch_transport(monkeypatch, request_threads: list[int], clients: list[str]) -> None:
"""Answer every request locally and record the thread that served it.
The provider's module holds the shared ``httpx`` module, so the reference
itself is replaced with a namespace exposing both client factories. That
keeps the test hermetic — no request leaves the machine — whichever
factory the provider uses. Each factory records its name in ``clients``
so a test can assert which one the provider reached for.
"""
def respond(request: httpx.Request) -> httpx.Response:
request_threads.append(threading.get_ident())
return httpx.Response(200, json={"results": {"web": []}})
transport = httpx.MockTransport(respond)
def async_client(**kwargs):
clients.append("AsyncClient")
return httpx.AsyncClient(transport=transport, **kwargs)
def sync_client(**kwargs):
clients.append("Client")
return httpx.Client(transport=transport, **kwargs)
monkeypatch.setattr(
youcom_tools,
"httpx",
SimpleNamespace(
AsyncClient=async_client,
Client=sync_client,
HTTPStatusError=httpx.HTTPStatusError,
),
)
def _patch_config(monkeypatch, load_config=None) -> None:
def config():
return SimpleNamespace(get_tool_config=lambda _name: SimpleNamespace(model_extra={"api_key": "synthetic-key"}))
monkeypatch.setattr(youcom_tools, "get_app_config", load_config or config)
async def test_youcom_search_request_runs_on_the_event_loop(monkeypatch) -> None:
"""The request is awaited on the loop, not dispatched to an executor thread."""
request_threads: list[int] = []
clients: list[str] = []
loop_thread = threading.get_ident()
_patch_transport(monkeypatch, request_threads, clients)
_patch_config(monkeypatch)
result = await youcom_tools.web_search_tool.ainvoke({"query": "vector databases"})
assert '"error": "No results found"' in result
assert request_threads, "the request must reach the transport"
assert all(thread == loop_thread for thread in request_threads)
assert clients == ["AsyncClient"], "the request must go out through the async client"
async def test_youcom_config_lookup_does_not_block_loop(monkeypatch) -> None:
"""The tool body runs on the loop, so the config file read must not."""
config_threads: list[int] = []
loop_thread = threading.get_ident()
def load_config():
# Keep a real file read in the lookup so the strict blocking-I/O gate
# fails if the tool resolves config on the event loop.
Path(__file__).read_text(encoding="utf-8")
config_threads.append(threading.get_ident())
return SimpleNamespace(get_tool_config=lambda _name: SimpleNamespace(model_extra={"api_key": "synthetic-key"}))
request_threads: list[int] = []
clients: list[str] = []
_patch_transport(monkeypatch, request_threads, clients)
_patch_config(monkeypatch, load_config)
result = await youcom_tools.web_search_tool.ainvoke({"query": "vector databases"})
assert '"error": "No results found"' in result
assert config_threads, "config lookup must run"
assert all(thread != loop_thread for thread in config_threads)
assert clients == ["AsyncClient"], "the request must go out through the async client"
async def test_youcom_user_agent_reads_metadata_off_the_loop_once(monkeypatch) -> None:
"""The cold User-Agent read goes to an executor; the warm hit stays in memory.
``_user_agent`` is cached, so only the first call reads the installed
package metadata. That read must stay off the event loop, and every later
call is a pure memory hit that must not pay an executor round-trip.
"""
loop_thread = threading.get_ident()
reads: list[int] = []
real_version = youcom_tools.version
def read_version(name: str) -> str:
reads.append(threading.get_ident())
return real_version(name)
monkeypatch.setattr(youcom_tools, "version", read_version)
youcom_tools._user_agent.cache_clear()
try:
cold = await youcom_tools._resolve_user_agent()
assert reads, "the cold call must read the installed package metadata"
assert all(thread != loop_thread for thread in reads), "the metadata read must not run on the event loop"
dispatched: list[object] = []
real_to_thread = asyncio.to_thread
async def spy_to_thread(func, /, *args, **kwargs):
dispatched.append(func)
return await real_to_thread(func, *args, **kwargs)
monkeypatch.setattr(asyncio, "to_thread", spy_to_thread)
reads.clear()
warm = await youcom_tools._resolve_user_agent()
assert warm == cold
assert not reads, "the warm call must not read the metadata again"
assert not dispatched, "the warm call must not pay an executor round-trip"
finally:
youcom_tools._user_agent.cache_clear()