mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 13:16:24 +00:00
* feat(jina): add process-local request admission Signed-off-by: 97three <2212371308@qq.com> * test(jina): cover closed admission owner loop Signed-off-by: 97three <2212371308@qq.com> * test(jina): retain admission across API redirects Signed-off-by: 97three <2212371308@qq.com> * fix(jina): drain redirect response cleanup before releasing admission Signed-off-by: 97three <2212371308@qq.com> --------- Signed-off-by: 97three <2212371308@qq.com>
753 lines
27 KiB
Python
753 lines
27 KiB
Python
"""Offline Jina admission regressions through the real HTTPX transport boundary."""
|
|
|
|
import asyncio
|
|
import json
|
|
import threading
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
from types import SimpleNamespace
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from deerflow.community.jina_ai import request_admission as admission_module
|
|
from deerflow.community.jina_ai import tools
|
|
from deerflow.community.jina_ai.jina_client import JinaClient
|
|
|
|
pytestmark = pytest.mark.anyio
|
|
POLICY = {"max_concurrent_requests": 1, "max_queue_size": 3, "max_wait_seconds": 5}
|
|
|
|
|
|
@pytest.fixture
|
|
def anyio_backend():
|
|
return "asyncio"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def isolated_policy(monkeypatch):
|
|
monkeypatch.setattr(admission_module, "_admission", None)
|
|
yield
|
|
budget = admission_module._admission
|
|
if budget is not None:
|
|
assert budget._active == 0
|
|
assert not budget._queue
|
|
|
|
|
|
@pytest.fixture
|
|
def install_transport(monkeypatch):
|
|
original = httpx.AsyncClient
|
|
monkeypatch.setenv("JINA_API_KEY", "dummy-test-key")
|
|
|
|
def install(handler):
|
|
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=httpx.MockTransport(handler), trust_env=False))
|
|
|
|
return install
|
|
|
|
|
|
async def test_tool_rejects_full_queue_before_dispatch(monkeypatch, install_transport):
|
|
entered, release = asyncio.Event(), asyncio.Event()
|
|
calls = 0
|
|
|
|
async def handle(request):
|
|
nonlocal calls
|
|
calls += 1
|
|
entered.set()
|
|
if calls == 1:
|
|
await release.wait()
|
|
return httpx.Response(401, text="offline status")
|
|
|
|
install_transport(handle)
|
|
config = SimpleNamespace(model_extra={"request_admission": {**POLICY, "max_queue_size": 0}})
|
|
monkeypatch.setattr(tools, "get_app_config", lambda: SimpleNamespace(get_tool_config=lambda name: config))
|
|
first = asyncio.create_task(tools.web_fetch_tool.ainvoke("https://example.com/first"))
|
|
try:
|
|
await asyncio.wait_for(entered.wait(), 2)
|
|
result = await tools.web_fetch_tool.ainvoke("https://example.com/second")
|
|
assert result == "Error: Local Jina request admission rejected: queue full"
|
|
assert calls == 1
|
|
assert set(tools.web_fetch_tool.args) == {"url"}
|
|
finally:
|
|
release.set()
|
|
await first
|
|
|
|
|
|
async def until(predicate):
|
|
async with asyncio.timeout(3):
|
|
while not predicate():
|
|
await asyncio.sleep(0)
|
|
|
|
|
|
def queued(count):
|
|
budget = admission_module._admission
|
|
if budget is None:
|
|
return False
|
|
with budget._lock:
|
|
return len(budget._queue) == count
|
|
|
|
|
|
def crawl(name="page", policy=POLICY, **kwargs):
|
|
return JinaClient().crawl(f"https://example.com/{name}", request_admission=policy, **kwargs)
|
|
|
|
|
|
class Stream(httpx.AsyncByteStream):
|
|
def __init__(self, *, chunks=(b"ok",), read_error=None, close_error=None, hold_read=False, hold_close=False):
|
|
self.chunks = chunks
|
|
self.read_error = read_error
|
|
self.close_error = close_error
|
|
self.hold_read = hold_read
|
|
self.hold_close = hold_close
|
|
self.reading = asyncio.Event()
|
|
self.closing = asyncio.Event()
|
|
self.release_read = asyncio.Event()
|
|
self.release_close = asyncio.Event()
|
|
self.closed = False
|
|
|
|
async def __aiter__(self):
|
|
self.reading.set()
|
|
if self.hold_read:
|
|
await self.release_read.wait()
|
|
if self.read_error:
|
|
raise self.read_error
|
|
for chunk in self.chunks:
|
|
yield chunk
|
|
|
|
async def aclose(self):
|
|
self.closing.set()
|
|
if self.hold_close:
|
|
await self.release_close.wait()
|
|
self.closed = True
|
|
if self.close_error:
|
|
raise self.close_error
|
|
|
|
|
|
@pytest.mark.parametrize("policy", [None, POLICY])
|
|
@pytest.mark.parametrize("cap", [None, 2])
|
|
async def test_default_concurrency_and_enabled_fifo(install_transport, policy, cap):
|
|
entered = []
|
|
releases = [asyncio.Event() for _ in range(8)]
|
|
|
|
async def handle(request):
|
|
number = int(json.loads(request.content)["url"].rsplit("/", 1)[1])
|
|
entered.append(number)
|
|
await releases[number].wait()
|
|
return httpx.Response(200, text="ok")
|
|
|
|
install_transport(handle)
|
|
options = None if policy is None else {**policy, "max_queue_size": 7}
|
|
tasks = []
|
|
try:
|
|
for i in range(8):
|
|
tasks.append(asyncio.create_task(crawl(str(i), options, max_response_bytes=cap)))
|
|
await until(lambda: len(entered) == i + 1 if policy is None else len(entered) == 1 and queued(i))
|
|
assert entered == (list(range(8)) if policy is None else [0])
|
|
for i in range(8):
|
|
releases[i].set()
|
|
assert await tasks[i] == "ok"
|
|
if policy is not None and i < 7:
|
|
await until(lambda: len(entered) == i + 2)
|
|
assert entered == list(range(i + 2))
|
|
finally:
|
|
for event in releases:
|
|
event.set()
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
|
|
@pytest.mark.parametrize("cancel_index", [0, 1])
|
|
async def test_queued_cancellation_removes_head_or_middle(install_transport, cancel_index):
|
|
stream = Stream(hold_read=True)
|
|
names = []
|
|
|
|
def handle(request):
|
|
names.append(json.loads(request.content)["url"].rsplit("/", 1)[1])
|
|
return httpx.Response(200, stream=stream) if len(names) == 1 else httpx.Response(200, text="ok")
|
|
|
|
install_transport(handle)
|
|
active = asyncio.create_task(crawl("active"))
|
|
await stream.reading.wait()
|
|
tasks = []
|
|
try:
|
|
for i in range(3):
|
|
tasks.append(asyncio.create_task(crawl(str(i))))
|
|
await until(lambda: queued(i + 1))
|
|
tasks[cancel_index].cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await tasks[cancel_index]
|
|
assert queued(2)
|
|
stream.release_read.set()
|
|
await active
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
assert names == ["active"] + [str(i) for i in range(3) if i != cancel_index]
|
|
finally:
|
|
stream.release_read.set()
|
|
await asyncio.gather(active, *tasks, return_exceptions=True)
|
|
|
|
|
|
@pytest.mark.parametrize("retry_budget", [None, 0.02])
|
|
async def test_wait_timeout_is_terminal_and_consumes_retry_budget(install_transport, retry_budget):
|
|
stream = Stream(hold_read=True)
|
|
calls = []
|
|
install_transport(lambda request: calls.append(request) or httpx.Response(200, stream=stream))
|
|
policy = {**POLICY, "max_wait_seconds": 0.02 if retry_budget is None else 5}
|
|
first = asyncio.create_task(crawl(policy=policy))
|
|
await stream.reading.wait()
|
|
try:
|
|
options = {} if retry_budget is None else {"max_retries": 2, "retry_budget_seconds": retry_budget}
|
|
result = await asyncio.wait_for(crawl(policy=policy, **options), 1)
|
|
assert result == "Error: Local Jina request admission rejected: wait expired"
|
|
assert len(calls) == 1
|
|
assert queued(0)
|
|
finally:
|
|
stream.release_read.set()
|
|
await first
|
|
|
|
|
|
@pytest.mark.parametrize("cap", [None, 2])
|
|
@pytest.mark.parametrize("failure", ["transport", "status", "read", "close", "byte_cap"])
|
|
async def test_failures_release_lease(install_transport, cap, failure):
|
|
calls = 0
|
|
stream = Stream(
|
|
chunks=(b"oversize",) if failure == "byte_cap" else (b"ok",),
|
|
read_error=httpx.ReadError("read failed") if failure == "read" else None,
|
|
close_error=RuntimeError("close failed") if failure == "close" else None,
|
|
)
|
|
|
|
def handle(request):
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls > 1:
|
|
return httpx.Response(200, text="ok")
|
|
if failure == "transport":
|
|
raise httpx.ConnectError("connect failed")
|
|
return httpx.Response(401 if failure == "status" else 200, stream=stream)
|
|
|
|
install_transport(handle)
|
|
result = await crawl(max_response_bytes=cap)
|
|
if failure == "byte_cap" and cap is None:
|
|
assert result == "oversize"
|
|
else:
|
|
assert result.startswith("Error:")
|
|
if failure != "transport":
|
|
assert stream.closed
|
|
assert await crawl(max_response_bytes=cap) == "ok"
|
|
|
|
|
|
@pytest.mark.parametrize("at_close", [False, True])
|
|
async def test_repeated_active_cancel_waits_for_cleanup(install_transport, at_close):
|
|
stream = Stream(hold_read=not at_close, hold_close=True)
|
|
calls = []
|
|
|
|
def handle(request):
|
|
calls.append(request)
|
|
return httpx.Response(200, stream=stream) if len(calls) == 1 else httpx.Response(200, text="ok")
|
|
|
|
install_transport(handle)
|
|
first = asyncio.create_task(crawl())
|
|
second = None
|
|
try:
|
|
await (stream.closing if at_close else stream.reading).wait()
|
|
first.cancel()
|
|
await stream.closing.wait()
|
|
second = asyncio.create_task(crawl())
|
|
await until(lambda: queued(1))
|
|
first.cancel()
|
|
await asyncio.sleep(0)
|
|
assert len(calls) == 1 and not first.done()
|
|
stream.release_close.set()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await first
|
|
assert stream.closed
|
|
assert await second == "ok"
|
|
finally:
|
|
stream.release_read.set()
|
|
stream.release_close.set()
|
|
await asyncio.gather(*[task for task in (first, second) if task], return_exceptions=True)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"field,values",
|
|
[
|
|
("max_concurrent_requests", [True, False, 0, -1, 1.5, "2", None]),
|
|
("max_queue_size", [True, False, -1, 1.5, "2", None]),
|
|
("max_wait_seconds", [True, False, 0, -1, float("inf"), float("nan"), "2", None, 10**400]),
|
|
],
|
|
)
|
|
async def test_invalid_policy_before_client_activity(monkeypatch, field, values):
|
|
def unexpected(**kwargs):
|
|
pytest.fail("invalid policy created a client")
|
|
|
|
monkeypatch.setattr(httpx, "AsyncClient", unexpected)
|
|
for value in values:
|
|
result = await crawl(policy={**POLICY, field: value})
|
|
assert result.startswith("Error:") and field in result
|
|
assert admission_module._admission is None
|
|
|
|
|
|
@pytest.mark.parametrize("value", [False, True, 1, [], {}, {**POLICY, "typo": 1}])
|
|
async def test_malformed_policy_before_client_activity(monkeypatch, value):
|
|
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: pytest.fail("invalid policy created a client"))
|
|
assert "request_admission requires" in await crawl(policy=value)
|
|
|
|
|
|
async def test_enabled_policy_is_immutable_and_applies_to_later_null_calls(install_transport):
|
|
calls = []
|
|
stream = Stream(hold_read=True)
|
|
|
|
def handle(request):
|
|
name = json.loads(request.content)["url"].rsplit("/", 1)[1]
|
|
calls.append(name)
|
|
return httpx.Response(200, stream=stream) if name == "enabled" else httpx.Response(200, text="ok")
|
|
|
|
install_transport(handle)
|
|
assert await crawl("before", policy=None) == "ok"
|
|
assert admission_module._admission is None
|
|
active = asyncio.create_task(crawl("enabled"))
|
|
omitted = None
|
|
try:
|
|
await stream.reading.wait()
|
|
budget = admission_module._admission
|
|
omitted = asyncio.create_task(crawl("omitted", policy=None))
|
|
await until(lambda: queued(1))
|
|
assert calls == ["before", "enabled"]
|
|
assert "restart required" in await crawl(policy={**POLICY, "max_concurrent_requests": 2})
|
|
assert admission_module._admission is budget
|
|
stream.release_read.set()
|
|
assert await active == "ok"
|
|
assert await omitted == "ok"
|
|
assert calls == ["before", "enabled", "omitted"]
|
|
finally:
|
|
stream.release_read.set()
|
|
await asyncio.gather(*[task for task in (active, omitted) if task], return_exceptions=True)
|
|
|
|
|
|
async def test_threads_and_event_loops_share_one_budget(install_transport):
|
|
entered = []
|
|
lock = threading.Lock()
|
|
release = threading.Event()
|
|
policy = {**POLICY, "max_queue_size": 7}
|
|
|
|
async def handle(request):
|
|
with lock:
|
|
entered.append(json.loads(request.content)["url"])
|
|
assert await asyncio.to_thread(release.wait, 3)
|
|
return httpx.Response(200, text="ok")
|
|
|
|
install_transport(handle)
|
|
with ThreadPoolExecutor(max_workers=8) as executor:
|
|
futures = [executor.submit(asyncio.run, crawl(str(i), policy)) for i in range(8)]
|
|
try:
|
|
await until(lambda: admission_module._admission is not None and queued(7))
|
|
with lock:
|
|
assert len(entered) == 1
|
|
release.set()
|
|
results = await asyncio.gather(*(asyncio.wrap_future(f) for f in futures))
|
|
assert results == ["ok"] * 8
|
|
finally:
|
|
release.set()
|
|
|
|
|
|
async def test_grant_cancel_race_returns_reserved_permit(install_transport, monkeypatch):
|
|
stream = Stream(hold_read=True)
|
|
calls = []
|
|
|
|
def handle(request):
|
|
calls.append(request)
|
|
return httpx.Response(200, stream=stream) if len(calls) == 1 else httpx.Response(200, text="ok")
|
|
|
|
install_transport(handle)
|
|
first = asyncio.create_task(crawl())
|
|
await stream.reading.wait()
|
|
second = asyncio.create_task(crawl())
|
|
await until(lambda: queued(1))
|
|
original = admission_module._Ticket.wake
|
|
|
|
def cancel_at_grant(ticket):
|
|
second.cancel()
|
|
original(ticket)
|
|
|
|
monkeypatch.setattr(admission_module._Ticket, "wake", cancel_at_grant)
|
|
stream.release_read.set()
|
|
await first
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await second
|
|
assert await crawl() == "ok"
|
|
|
|
|
|
async def test_expired_grant_never_dispatches(install_transport, monkeypatch):
|
|
stream = Stream(hold_read=True)
|
|
calls = []
|
|
|
|
def handle(request):
|
|
calls.append(request)
|
|
return httpx.Response(200, stream=stream) if len(calls) == 1 else httpx.Response(200, text="ok")
|
|
|
|
install_transport(handle)
|
|
first = asyncio.create_task(crawl())
|
|
await stream.reading.wait()
|
|
second = asyncio.create_task(crawl())
|
|
await until(lambda: queued(1))
|
|
original = admission_module._Ticket.wake
|
|
|
|
def expire_at_grant(ticket):
|
|
ticket.deadline = 0
|
|
original(ticket)
|
|
|
|
monkeypatch.setattr(admission_module._Ticket, "wake", expire_at_grant)
|
|
stream.release_read.set()
|
|
await first
|
|
assert "wait expired" in await second
|
|
assert len(calls) == 1
|
|
assert await crawl() == "ok"
|
|
|
|
|
|
@pytest.mark.parametrize("expired", [False, True])
|
|
async def test_closed_owner_loop_returns_reserved_permit(expired):
|
|
budget = admission_module.get_admission(POLICY)
|
|
main_loop = asyncio.get_running_loop()
|
|
active = admission_module._Ticket(main_loop, main_loop.create_future(), float("inf"), state="granted")
|
|
|
|
def closed_loop_ticket():
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
return admission_module._Ticket(loop, loop.create_future(), 0 if expired else float("inf"))
|
|
finally:
|
|
loop.close()
|
|
|
|
with ThreadPoolExecutor(max_workers=1) as executor:
|
|
ticket = await asyncio.wrap_future(executor.submit(closed_loop_ticket))
|
|
|
|
with budget._lock:
|
|
budget._active = 1
|
|
budget._queue.append(ticket)
|
|
|
|
budget._finish(active)
|
|
assert ticket.state == "released"
|
|
assert budget._active == 0
|
|
assert not budget._queue
|
|
|
|
# Finishing the orphan again must not decrement a granted or expired ticket.
|
|
budget._finish(ticket)
|
|
assert budget._active == 0
|
|
async with budget.attempt(None):
|
|
assert budget._active == 1
|
|
assert budget._active == 0
|
|
|
|
|
|
async def test_retry_releases_during_backoff_and_rejoins_fifo(install_transport, monkeypatch):
|
|
from deerflow.community.jina_ai import jina_client
|
|
|
|
backoff, resume = asyncio.Event(), asyncio.Event()
|
|
holding = Stream(hold_read=True)
|
|
order = []
|
|
original_sleep = asyncio.sleep
|
|
|
|
async def sleep(delay):
|
|
if delay > 0:
|
|
backoff.set()
|
|
await resume.wait()
|
|
else:
|
|
await original_sleep(delay)
|
|
|
|
def handle(request):
|
|
name = json.loads(request.content)["url"].rsplit("/", 1)[1]
|
|
order.append(name)
|
|
if order == ["retry"]:
|
|
return httpx.Response(503, text="retry", headers={"Retry-After": "0"})
|
|
return httpx.Response(200, stream=holding) if name == "holding" else httpx.Response(200, text="ok")
|
|
|
|
monkeypatch.setattr(jina_client.asyncio, "sleep", sleep)
|
|
install_transport(handle)
|
|
retry = asyncio.create_task(crawl("retry", max_retries=1))
|
|
await backoff.wait()
|
|
active = asyncio.create_task(crawl("holding"))
|
|
await holding.reading.wait()
|
|
earlier = asyncio.create_task(crawl("earlier"))
|
|
await until(lambda: queued(1))
|
|
resume.set()
|
|
await until(lambda: queued(2))
|
|
holding.release_read.set()
|
|
assert await asyncio.gather(retry, active, earlier) == ["ok"] * 3
|
|
assert order == ["retry", "holding", "earlier", "retry"]
|
|
|
|
|
|
async def test_readability_failure_holds_no_permit(install_transport, monkeypatch):
|
|
install_transport(lambda request: httpx.Response(200, text="<p>ok</p>"))
|
|
config = SimpleNamespace(model_extra={"request_admission": POLICY})
|
|
monkeypatch.setattr(tools, "get_app_config", lambda: SimpleNamespace(get_tool_config=lambda name: config))
|
|
|
|
def extract(*args, **kwargs):
|
|
assert admission_module._admission._active == 0
|
|
raise RuntimeError("extraction failed")
|
|
|
|
monkeypatch.setattr(tools.readability_extractor, "extract_article", extract)
|
|
with pytest.raises(RuntimeError, match="extraction failed"):
|
|
await tools.web_fetch_tool.ainvoke("https://example.com")
|
|
assert await crawl() == "<p>ok</p>"
|
|
|
|
|
|
async def test_capacity_greater_than_one_and_nonempty_full_queue(install_transport):
|
|
streams = [Stream(hold_read=True) for _ in range(3)]
|
|
calls = []
|
|
policy = {**POLICY, "max_concurrent_requests": 3, "max_queue_size": 2}
|
|
|
|
def handle(request):
|
|
calls.append(request)
|
|
return httpx.Response(200, stream=streams[len(calls) - 1]) if len(calls) <= 3 else httpx.Response(200, text="ok")
|
|
|
|
install_transport(handle)
|
|
tasks = [asyncio.create_task(crawl(str(i), policy)) for i in range(5)]
|
|
try:
|
|
await until(lambda: queued(2))
|
|
assert len(calls) == 3
|
|
assert "queue full" in await crawl("full", policy, max_retries=2)
|
|
assert len(calls) == 3
|
|
for stream in streams:
|
|
stream.release_read.set()
|
|
assert await asyncio.gather(*tasks) == ["ok"] * 5
|
|
finally:
|
|
for stream in streams:
|
|
stream.release_read.set()
|
|
await asyncio.gather(*tasks, return_exceptions=True)
|
|
|
|
|
|
async def test_enabled_retries_reuse_one_client(monkeypatch):
|
|
original_client = httpx.AsyncClient
|
|
clients = []
|
|
calls = 0
|
|
|
|
def handle(request):
|
|
nonlocal calls
|
|
calls += 1
|
|
if calls == 1:
|
|
return httpx.Response(503, text="retry", headers={"Retry-After": "0"})
|
|
return httpx.Response(200, text="ok")
|
|
|
|
def client(**kwargs):
|
|
result = original_client(transport=httpx.MockTransport(handle), trust_env=False)
|
|
clients.append(result)
|
|
return result
|
|
|
|
monkeypatch.setattr(httpx, "AsyncClient", client)
|
|
assert await crawl(max_retries=1, retry_budget_seconds=2) == "ok"
|
|
assert calls == 2
|
|
assert len(clients) == 1
|
|
|
|
|
|
async def test_client_pool_close_does_not_hold_attempt_permit(monkeypatch):
|
|
original_client = httpx.AsyncClient
|
|
closing, release = asyncio.Event(), asyncio.Event()
|
|
calls = []
|
|
clients = []
|
|
|
|
class Client(original_client):
|
|
async def aclose(self):
|
|
if self is clients[0]:
|
|
closing.set()
|
|
await release.wait()
|
|
await super().aclose()
|
|
|
|
def handle(request):
|
|
calls.append(request)
|
|
return httpx.Response(200, text="ok")
|
|
|
|
def client(**kwargs):
|
|
result = Client(transport=httpx.MockTransport(handle), trust_env=False)
|
|
clients.append(result)
|
|
return result
|
|
|
|
monkeypatch.setattr(httpx, "AsyncClient", client)
|
|
first = asyncio.create_task(crawl())
|
|
try:
|
|
await closing.wait()
|
|
assert await crawl("second") == "ok"
|
|
assert len(calls) == 2
|
|
assert queued(0)
|
|
release.set()
|
|
assert await first == "ok"
|
|
finally:
|
|
release.set()
|
|
await asyncio.gather(first, return_exceptions=True)
|
|
|
|
|
|
async def test_cancel_with_response_and_client_close_failures_preserves_cancel_and_permit(monkeypatch):
|
|
original_client = httpx.AsyncClient
|
|
stream = Stream(hold_read=True, close_error=RuntimeError("response close failed"))
|
|
clients = []
|
|
calls = []
|
|
|
|
class Client(original_client):
|
|
async def aclose(self):
|
|
await super().aclose()
|
|
if self is clients[0]:
|
|
raise RuntimeError("client close failed")
|
|
|
|
def handle(request):
|
|
calls.append(request)
|
|
return httpx.Response(200, stream=stream) if len(calls) == 1 else httpx.Response(200, text="ok")
|
|
|
|
def client(**kwargs):
|
|
result = Client(transport=httpx.MockTransport(handle), trust_env=False)
|
|
clients.append(result)
|
|
return result
|
|
|
|
monkeypatch.setattr(httpx, "AsyncClient", client)
|
|
first = asyncio.create_task(crawl("cancelled"))
|
|
second = None
|
|
try:
|
|
await stream.reading.wait()
|
|
second = asyncio.create_task(crawl("next"))
|
|
await until(lambda: queued(1))
|
|
first.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await first
|
|
assert stream.closed
|
|
assert await second == "ok"
|
|
assert len(calls) == 2
|
|
finally:
|
|
stream.release_read.set()
|
|
await asyncio.gather(*[task for task in (first, second) if task], return_exceptions=True)
|
|
|
|
|
|
async def test_successful_queue_wait_reduces_original_http_budget(install_transport, monkeypatch):
|
|
loop = asyncio.get_running_loop()
|
|
now = [loop.time()]
|
|
monkeypatch.setattr(loop, "time", lambda: now[0])
|
|
holding = Stream(hold_read=True)
|
|
second_stream = Stream(hold_read=True)
|
|
calls = []
|
|
|
|
def handle(request):
|
|
calls.append(request)
|
|
if len(calls) == 1:
|
|
return httpx.Response(200, stream=holding)
|
|
assert request.extensions["timeout"]["read"] == pytest.approx(0.25)
|
|
return httpx.Response(200, stream=second_stream)
|
|
|
|
install_transport(handle)
|
|
first = asyncio.create_task(crawl())
|
|
await holding.reading.wait()
|
|
second = asyncio.create_task(crawl(max_retries=1, retry_budget_seconds=0.5))
|
|
try:
|
|
await until(lambda: queued(1))
|
|
now[0] += 0.25
|
|
holding.release_read.set()
|
|
await first
|
|
await second_stream.reading.wait()
|
|
now[0] += 0.3
|
|
assert "retry time budget exhausted" in await second
|
|
assert second_stream.closed
|
|
assert len(calls) == 2
|
|
finally:
|
|
holding.release_read.set()
|
|
second_stream.release_read.set()
|
|
await asyncio.gather(first, second, return_exceptions=True)
|
|
|
|
|
|
@pytest.mark.parametrize("limit", [1, 2])
|
|
async def test_admission_preserves_decoded_byte_cap(install_transport, limit):
|
|
import gzip
|
|
|
|
stream = Stream(chunks=(gzip.compress(b"ok"),))
|
|
install_transport(lambda request: httpx.Response(200, stream=stream, headers={"Content-Encoding": "gzip"}))
|
|
result = await crawl(max_response_bytes=limit)
|
|
assert ("max_response_bytes" in result) if limit == 1 else result == "ok"
|
|
assert stream.closed
|
|
|
|
|
|
async def test_client_close_failure_returns_permit(monkeypatch):
|
|
original_client = httpx.AsyncClient
|
|
clients = []
|
|
|
|
class Client(original_client):
|
|
async def aclose(self):
|
|
await super().aclose()
|
|
if self is clients[0]:
|
|
raise RuntimeError("client close failed")
|
|
|
|
def client(**kwargs):
|
|
result = Client(transport=httpx.MockTransport(lambda request: httpx.Response(200, text="ok")), trust_env=False)
|
|
clients.append(result)
|
|
return result
|
|
|
|
monkeypatch.setattr(httpx, "AsyncClient", client)
|
|
assert "client close failed" in await crawl()
|
|
assert await crawl() == "ok"
|
|
|
|
|
|
@pytest.mark.parametrize("max_retries", [0, 1])
|
|
async def test_admitted_redirect_keeps_post_and_strips_cross_host_key(monkeypatch, max_retries):
|
|
requests = []
|
|
|
|
def handle(request):
|
|
assert json.loads(request.read()) == {"url": "https://example.com/page"}
|
|
assert admission_module._admission._active == 1
|
|
requests.append(request)
|
|
if request.url.host == "r.jina.ai":
|
|
return httpx.Response(307, headers={"Location": "https://redirect.example/final"})
|
|
return httpx.Response(200, text="Fetched page")
|
|
|
|
original = httpx.AsyncClient
|
|
monkeypatch.setenv("JINA_API_KEY", "dummy-test-key")
|
|
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original(transport=httpx.MockTransport(handle), **kwargs))
|
|
|
|
assert await crawl(max_retries=max_retries, trust_env=False) == "Fetched page"
|
|
assert [request.url.host for request in requests] == ["r.jina.ai", "redirect.example"]
|
|
assert all(request.method == "POST" for request in requests)
|
|
assert requests[0].headers["Authorization"] == "Bearer dummy-test-key"
|
|
assert "Authorization" not in requests[1].headers
|
|
assert admission_module._admission._active == 0
|
|
|
|
|
|
@pytest.mark.parametrize("exit_reason", ["cancel", "deadline"])
|
|
async def test_redirect_cleanup_finishes_before_next_permit(monkeypatch, exit_reason):
|
|
redirect = Stream(hold_close=True)
|
|
dispatched = []
|
|
loop = asyncio.get_running_loop()
|
|
original_time = loop.time
|
|
clock_offset = [0.0]
|
|
monkeypatch.setattr(loop, "time", lambda: original_time() + clock_offset[0])
|
|
|
|
def handle(request):
|
|
name = json.loads(request.content)["url"].rsplit("/", 1)[1]
|
|
dispatched.append(name)
|
|
if name == "first":
|
|
assert request.url.host == "r.jina.ai"
|
|
return httpx.Response(307, headers={"Location": "https://redirect.example/final"}, stream=redirect)
|
|
assert redirect.closed
|
|
return httpx.Response(200, text="ok")
|
|
|
|
original_client = httpx.AsyncClient
|
|
monkeypatch.setattr(httpx, "AsyncClient", lambda **kwargs: original_client(transport=httpx.MockTransport(handle), **kwargs))
|
|
first = asyncio.create_task(crawl("first", trust_env=False, max_retries=int(exit_reason == "deadline"), retry_budget_seconds=1))
|
|
second = None
|
|
try:
|
|
await asyncio.wait_for(redirect.closing.wait(), 3)
|
|
second = asyncio.create_task(crawl("second", trust_env=False))
|
|
await until(lambda: queued(1))
|
|
if exit_reason == "cancel":
|
|
first.cancel()
|
|
else:
|
|
clock_offset[0] = 2.0
|
|
await until(lambda: first.cancelling() or first.done())
|
|
# Let cancellation and the HTTPX exception cleanup path run while the
|
|
# transport close is deliberately held at the event barrier.
|
|
for _ in range(3):
|
|
await asyncio.sleep(0)
|
|
assert not first.done()
|
|
assert not redirect.closed
|
|
assert dispatched == ["first"]
|
|
assert queued(1)
|
|
assert admission_module._admission._active == 1
|
|
|
|
redirect.release_close.set()
|
|
if exit_reason == "cancel":
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await first
|
|
else:
|
|
assert "retry time budget exhausted" in await first
|
|
assert await second == "ok"
|
|
assert redirect.closed
|
|
assert dispatched == ["first", "second"]
|
|
assert admission_module._admission._active == 0
|
|
finally:
|
|
redirect.release_close.set()
|
|
await asyncio.gather(*(task for task in (first, second) if task is not None), return_exceptions=True)
|