From ee006e0c8651d1a96a1ddcd642d1095f89f594a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=93=88=E5=9F=BA=E7=B1=B3?= <140241684+BlueX888@users.noreply.github.com> Date: Fri, 6 Nov 2026 10:26:42 +0800 Subject: [PATCH] fix(community): initialize InfoQuest TLS clients off the event loop (#7899) * fix(community): initialize InfoQuest TLS clients off the event loop InfoQuest's fetch, web search and image search constructed httpx.AsyncClient inline in their coroutines, so the default transport loaded the TLS trust store synchronously on the agent event loop (~125 ms per call in a local probe). RAGFlow, LightRAG, You.com, GroundRoute and Webz already construct their clients with asyncio.to_thread; InfoQuest was the missed sibling. Move the three constructions to asyncio.to_thread and cover all three methods in the blocking-I/O TLS test. Requests, cleanup, timeouts and responses stay on the event loop. Co-Authored-By: Claude Code * docs: qualify the off-loop HTTPX provider list The list is not exhaustive; several community clients still construct their client inline. Use non-exhaustive wording until the follow-up sweep lands. Co-Authored-By: Claude Code * docs: correct the off-loop provider list after merging main --------- Co-authored-by: Claude Code Co-authored-by: Willem Jiang --- README.md | 2 +- backend/AGENTS.md | 14 +++---- .../community/infoquest/infoquest_client.py | 7 ++-- .../blocking_io/test_search_client_ssl.py | 41 +++++++++++++++++++ 4 files changed, 53 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index f2c227586b..2eaf59ab3c 100644 --- a/README.md +++ b/README.md @@ -254,7 +254,7 @@ fences, including unclosed fences, in complete and streamed replies. Brave web search preserves valid entries in mixed result lists. Malformed response containers or lists containing no usable entries return a structured format error; missing, null, or empty results keep the existing "No results found" response. Format errors also log the malformed container's path and type, or the absence of usable result objects, without including search queries, credentials, or payload values. - You.com, GroundRoute and Webz initialize HTTPX clients in a worker thread so synchronous TLS trust loading does not block the agent event loop; requests and session cleanup remain asynchronous. + Several providers, including You.com, GroundRoute, Webz and InfoQuest, initialize HTTPX clients in a worker thread so synchronous TLS trust loading does not block the agent event loop; requests and session cleanup remain asynchronous. GroundRoute web search and fetch contain malformed HTTP-success payloads the same way: a non-object payload, a `results` container that is not a list, or a non-empty list with no object entries returns the provider's format error, while non-object entries in an otherwise valid list are skipped in order. Missing, null, or empty results keep the existing "No results found" response. diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 236f67134a..8a655f174b 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -136,13 +136,13 @@ Read the nearest scoped `AGENTS.md` for subsystem ownership. Read-only knowledge: [contract](docs/KNOWLEDGE.md) covers independent retrieval tools, scope intersection and extension-owned providers. -HTTPX clients for RAGFlow, LightRAG, You.com, GroundRoute, Webz, Crawl4AI and -Browserless use `asyncio.to_thread` for synchronous TLS setup; requests and -cleanup stay on-loop, and the construction is owned as a task so cancelling -during setup still closes the client it builds. -The list is non-exhaustive: jsonify, SearXNG, Jina and InfoQuest -still construct their clients on the loop and should follow the same rule when -touched. +HTTPX clients for several community providers, including RAGFlow, LightRAG, +You.com, GroundRoute, Webz, Crawl4AI, Browserless and InfoQuest, use +`asyncio.to_thread` for synchronous TLS setup; requests and cleanup stay on-loop, +and the construction is owned as a task so cancelling during setup still closes +the client it builds. +The list is non-exhaustive: jsonify, SearXNG and Jina still construct their +clients on the loop and should follow the same rule when touched. ### Harness / App Split diff --git a/backend/packages/harness/deerflow/community/infoquest/infoquest_client.py b/backend/packages/harness/deerflow/community/infoquest/infoquest_client.py index 356ac43c4e..d7f68e3450 100644 --- a/backend/packages/harness/deerflow/community/infoquest/infoquest_client.py +++ b/backend/packages/harness/deerflow/community/infoquest/infoquest_client.py @@ -4,6 +4,7 @@ In order to set this up, follow instructions at: https://docs.byteplus.com/en/docs/InfoQuest/What_is_Info_Quest """ +import asyncio import json import logging import os @@ -79,7 +80,7 @@ class InfoQuestClient: logger.debug("Sending crawl request to InfoQuest API") try: - async with httpx.AsyncClient(follow_redirects=True) as client: + async with await asyncio.to_thread(httpx.AsyncClient, follow_redirects=True) as client: response = await client.post("https://reader.infoquest.bytepluses.com", headers=headers, json=data, timeout=_REQUEST_TIMEOUT_SECONDS) # Check if status code is not 200 @@ -196,7 +197,7 @@ class InfoQuestClient: if site != "": params["site"] = site - async with httpx.AsyncClient(follow_redirects=True) as client: + async with await asyncio.to_thread(httpx.AsyncClient, follow_redirects=True) as client: response = await client.post("https://search.infoquest.bytepluses.com", headers=headers, json=params, timeout=_REQUEST_TIMEOUT_SECONDS) response.raise_for_status() @@ -440,7 +441,7 @@ class InfoQuestClient: elif self.image_size: logger.warning(f"image_size {self.image_size} is not valid, must be 'l', 'm', or 'i'") - async with httpx.AsyncClient(follow_redirects=True) as client: + async with await asyncio.to_thread(httpx.AsyncClient, follow_redirects=True) as client: response = await client.post("https://search.infoquest.bytepluses.com", headers=headers, json=params, timeout=_REQUEST_TIMEOUT_SECONDS) response.raise_for_status() diff --git a/backend/tests/blocking_io/test_search_client_ssl.py b/backend/tests/blocking_io/test_search_client_ssl.py index 7e5e763ee0..e603047b31 100644 --- a/backend/tests/blocking_io/test_search_client_ssl.py +++ b/backend/tests/blocking_io/test_search_client_ssl.py @@ -9,6 +9,7 @@ import httpx import pytest from deerflow.community.groundroute import tools as groundroute +from deerflow.community.infoquest.infoquest_client import InfoQuestClient from deerflow.community.webz import tools as webz from deerflow.community.youcom import tools as youcom @@ -69,3 +70,43 @@ async def test_search_tool_tls_setup_runs_off_loop(provider, monkeypatch): assert contexts assert len(clients) == 1 assert clients[0].is_closed + + +@pytest.mark.asyncio +@pytest.mark.parametrize("operation", ["fetch", "web_search_raw_results", "image_search_raw_results"]) +async def test_infoquest_client_tls_setup_runs_off_loop(operation, monkeypatch): + loop_thread = threading.get_ident() + original_context = ssl.create_default_context + contexts = [] + clients = [] + + def create_context(*args, **kwargs): + assert threading.get_ident() != loop_thread, "TLS trust loading blocks the event loop" + context = original_context(*args, **kwargs) + contexts.append(context) + return context + + async def send(self, request, **kwargs): + assert threading.get_ident() == loop_thread + clients.append(self) + if operation == "fetch": + assert request.url.host == "reader.infoquest.bytepluses.com" + return httpx.Response(200, request=request, json={"reader_result": "Synthetic"}) + assert request.url.host == "search.infoquest.bytepluses.com" + assert json.loads(request.content)["query"] == "synthetic query" + return httpx.Response(200, request=request, json={"search_result": {"results": []}}) + + monkeypatch.setattr(ssl, "create_default_context", create_context) + monkeypatch.setattr(httpx.AsyncClient, "send", send) + + client = InfoQuestClient() + if operation == "fetch": + result = await client.fetch("https://example.com") + assert "Synthetic" in result + else: + result = await getattr(client, operation)("synthetic query", "") + assert result == {"search_result": {"results": []}} + + assert contexts + assert len(clients) == 1 + assert clients[0].is_closed