A provider whose backend keeps the response open without finishing (hanging HTTP server, stuck SDK call) stalled the web_extract tool call — and with a sync provider, the borrowed thread — indefinitely. The dispatch in tools/web_tools_extract._dispatch_extract now runs under asyncio.wait_for with web.extract_timeout (config.yaml, default 120s; 0 disables). On timeout the tool returns structured per-URL error entries, and the one-shot keyless rescue still gets its chance when eligible. Salvaged from PR #57180 by @liuhao1024 (base predated the web_tools decomposition; re-applied at the _dispatch_extract seam, env-var timeout replaced with the web.* config section per the .env-is-for-secrets rule, and the timeout path made rescue-aware). Inspired by Claude Code 2.1.268: "Fixed WebFetch hanging indefinitely on a server that keeps the response open without finishing; a fetch now fails after 300 seconds." Fixes #57155 Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
57 lines
1.9 KiB
Python
57 lines
1.9 KiB
Python
"""web_extract provider dispatch must be wall-clock bounded (#57155, salvage #57180).
|
|
|
|
A backend that keeps the response open without finishing (hanging HTTP server,
|
|
stuck SDK) must produce per-URL timeout errors instead of stalling the tool
|
|
call — and the event loop — indefinitely.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
|
|
import pytest
|
|
|
|
from tools import web_tools_extract as wte
|
|
|
|
|
|
class _HangingAsyncProvider:
|
|
name = "hanging-async"
|
|
|
|
async def extract(self, urls, format=None):
|
|
await asyncio.sleep(9999)
|
|
|
|
|
|
class _HangingSyncProvider:
|
|
name = "hanging-sync"
|
|
|
|
def extract(self, urls, format=None):
|
|
import time
|
|
|
|
# Longer than the patched 0.2s cap, short enough that asyncio.run's
|
|
# executor-join at loop shutdown doesn't hang the test.
|
|
time.sleep(2)
|
|
|
|
|
|
@pytest.mark.parametrize("provider", [_HangingAsyncProvider(), _HangingSyncProvider()],
|
|
ids=["async", "sync-to-thread"])
|
|
def test_hanging_provider_returns_per_url_timeout_errors(monkeypatch, provider):
|
|
monkeypatch.setattr(wte, "_extract_timeout_seconds", lambda: 0.2)
|
|
monkeypatch.setattr(wte, "_rescue_eligible", lambda p: False)
|
|
urls = ["https://example.com/a", "https://example.com/b"]
|
|
results = asyncio.run(wte._dispatch_extract(provider, urls, None))
|
|
assert [r["url"] for r in results] == urls
|
|
for r in results:
|
|
assert "timed out" in r["error"].lower()
|
|
assert provider.name in r["error"]
|
|
|
|
|
|
def test_timeout_zero_disables_the_cap(monkeypatch):
|
|
class _FastProvider:
|
|
name = "fast"
|
|
|
|
async def extract(self, urls, format=None):
|
|
return [{"url": u, "content": "ok"} for u in urls]
|
|
|
|
monkeypatch.setattr(wte, "_extract_timeout_seconds", lambda: 0.0)
|
|
results = asyncio.run(wte._dispatch_extract(_FastProvider(), ["https://example.com/x"], None))
|
|
assert results[0]["content"] == "ok"
|