mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-11-12 22:20:33 +00:00
* fix(models): close Codex async HTTP requests on cancellation * test(models): isolate loopback cancellation checks from proxies * docs: record Codex cancellation fix in changelog * docs: translate Codex cancellation changelog entry --------- Co-authored-by: Willem Jiang <willem.jiang@gmail.com> Co-authored-by: Claude Code <noreply@anthropic.com>
206 lines
7.3 KiB
Python
206 lines
7.3 KiB
Python
"""Exercise production Codex HTTP ownership with a real loopback SSE peer."""
|
|
|
|
import asyncio
|
|
import json
|
|
import threading
|
|
|
|
import httpx
|
|
import pytest
|
|
from langchain_core.messages import HumanMessage
|
|
|
|
from deerflow.models import openai_codex_provider as provider
|
|
from deerflow.models.credential_loader import CodexCliCredential
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("phase", ["headers", "body"])
|
|
@pytest.mark.parametrize("api", ["ainvoke", "astream"])
|
|
async def test_cancelled_ainvoke_closes_upstream_connection(monkeypatch, phase, api):
|
|
monkeypatch.setenv("NO_PROXY", "127.0.0.1,localhost")
|
|
monkeypatch.setenv("no_proxy", "127.0.0.1,localhost")
|
|
started = asyncio.Event()
|
|
disconnected = asyncio.Event()
|
|
release = asyncio.Event()
|
|
handlers = set()
|
|
|
|
async def handle(reader, writer):
|
|
task = asyncio.current_task()
|
|
handlers.add(task)
|
|
try:
|
|
headers = await reader.readuntil(b"\r\n\r\n")
|
|
size = next(int(line.split(b":", 1)[1]) for line in headers.split(b"\r\n") if line.lower().startswith(b"content-length:"))
|
|
await reader.readexactly(size)
|
|
if phase == "body":
|
|
writer.write(b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nConnection: close\r\n\r\n")
|
|
writer.write(b'data: {"type":"response.created","response":{"id":"synthetic"}}\n\n')
|
|
await writer.drain()
|
|
started.set()
|
|
# The fixture never completes the model response. Only client EOF
|
|
# can satisfy the assertion, and teardown always releases the peer.
|
|
eof = asyncio.create_task(reader.read())
|
|
done = asyncio.create_task(release.wait())
|
|
try:
|
|
finished, _ = await asyncio.wait({eof, done}, return_when=asyncio.FIRST_COMPLETED)
|
|
if eof in finished and eof.result() == b"":
|
|
disconnected.set()
|
|
finally:
|
|
eof.cancel()
|
|
done.cancel()
|
|
await asyncio.gather(eof, done, return_exceptions=True)
|
|
finally:
|
|
writer.close()
|
|
await writer.wait_closed()
|
|
handlers.discard(task)
|
|
|
|
server = await asyncio.start_server(handle, "127.0.0.1", 0)
|
|
monkeypatch.setattr(provider, "CODEX_BASE_URL", f"http://127.0.0.1:{server.sockets[0].getsockname()[1]}")
|
|
monkeypatch.setattr(provider, "load_codex_cli_credential", lambda: CodexCliCredential("synthetic-token", "synthetic-account"))
|
|
model = provider.CodexChatModel(retry_max_attempts=1)
|
|
|
|
async def invoke():
|
|
messages = [HumanMessage(content="Synthetic cancellation test")]
|
|
if api == "ainvoke":
|
|
await model.ainvoke(messages)
|
|
else:
|
|
async for _ in model.astream(messages):
|
|
pass
|
|
|
|
task = asyncio.create_task(invoke())
|
|
try:
|
|
await asyncio.wait_for(started.wait(), 5)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
await asyncio.wait_for(disconnected.wait(), 2)
|
|
finally:
|
|
release.set()
|
|
task.cancel()
|
|
await asyncio.gather(task, return_exceptions=True)
|
|
server.close()
|
|
await server.wait_closed()
|
|
if handlers:
|
|
await asyncio.wait_for(asyncio.gather(*handlers), 5)
|
|
|
|
|
|
@pytest.fixture
|
|
def model(monkeypatch):
|
|
monkeypatch.setattr(provider, "load_codex_cli_credential", lambda: CodexCliCredential("synthetic-token", "synthetic-account"))
|
|
return provider.CodexChatModel(retry_max_attempts=3)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_cancellation_during_retry_closes_response_and_does_not_retry(monkeypatch, model):
|
|
waiting = asyncio.Event()
|
|
calls = []
|
|
responses = []
|
|
clients = []
|
|
client_class = httpx.AsyncClient
|
|
|
|
def respond(request):
|
|
calls.append(request)
|
|
response = httpx.Response(429, headers={"Retry-After": "600"}, json={"error": "synthetic"})
|
|
responses.append(response)
|
|
return response
|
|
|
|
def create_client(**kwargs):
|
|
client = client_class(transport=httpx.MockTransport(respond), **kwargs)
|
|
clients.append(client)
|
|
return client
|
|
|
|
async def sleep(delay):
|
|
assert delay == 600
|
|
assert all(response.is_closed for response in responses)
|
|
assert all(client.is_closed for client in clients)
|
|
waiting.set()
|
|
await asyncio.Event().wait()
|
|
|
|
monkeypatch.setattr(provider.httpx, "AsyncClient", create_client)
|
|
monkeypatch.setattr(provider.asyncio, "sleep", sleep)
|
|
task = asyncio.create_task(model.ainvoke("Synthetic retry cancellation"))
|
|
try:
|
|
await asyncio.wait_for(waiting.wait(), 5)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
assert len(calls) == 1
|
|
finally:
|
|
task.cancel()
|
|
await asyncio.gather(task, return_exceptions=True)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tls_setup_is_off_loop_and_cancellation_creates_no_client(monkeypatch, model):
|
|
loop = asyncio.get_running_loop()
|
|
started = asyncio.Event()
|
|
finished = asyncio.Event()
|
|
release = threading.Event()
|
|
loop_thread = threading.get_ident()
|
|
real_context = httpx.create_ssl_context
|
|
|
|
def context():
|
|
assert threading.get_ident() != loop_thread
|
|
loop.call_soon_threadsafe(started.set)
|
|
try:
|
|
assert release.wait(5)
|
|
return real_context()
|
|
finally:
|
|
loop.call_soon_threadsafe(finished.set)
|
|
|
|
def unexpected_client(**kwargs):
|
|
pytest.fail("Cancellation before TLS setup finishes must not create an HTTP client")
|
|
|
|
monkeypatch.setattr(provider.httpx, "create_ssl_context", context)
|
|
monkeypatch.setattr(provider.httpx, "AsyncClient", unexpected_client)
|
|
task = asyncio.create_task(model.ainvoke("Synthetic TLS cancellation"))
|
|
try:
|
|
await asyncio.wait_for(started.wait(), 5)
|
|
task.cancel()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
finally:
|
|
release.set()
|
|
await asyncio.gather(task, return_exceptions=True)
|
|
await asyncio.wait_for(finished.wait(), 5)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_repeated_cancellation_drains_client_close(monkeypatch, model):
|
|
closing = asyncio.Event()
|
|
release = asyncio.Event()
|
|
clients = []
|
|
client_class = httpx.AsyncClient
|
|
|
|
def respond(request):
|
|
event = {"type": "response.completed", "response": {"output": []}}
|
|
return httpx.Response(200, text=f"data: {json.dumps(event)}\n\n")
|
|
|
|
class ClosingClient(client_class):
|
|
async def aclose(self):
|
|
closing.set()
|
|
await release.wait()
|
|
await super().aclose()
|
|
|
|
def create_client(**kwargs):
|
|
client = ClosingClient(transport=httpx.MockTransport(respond), **kwargs)
|
|
clients.append(client)
|
|
return client
|
|
|
|
monkeypatch.setattr(provider.httpx, "AsyncClient", create_client)
|
|
task = asyncio.create_task(model.ainvoke("Synthetic cleanup cancellation"))
|
|
try:
|
|
await asyncio.wait_for(closing.wait(), 5)
|
|
task.cancel()
|
|
await asyncio.sleep(0)
|
|
task.cancel()
|
|
await asyncio.sleep(0)
|
|
assert not task.done()
|
|
release.set()
|
|
with pytest.raises(asyncio.CancelledError):
|
|
await task
|
|
assert len(clients) == 1
|
|
assert clients[0].is_closed
|
|
finally:
|
|
release.set()
|
|
task.cancel()
|
|
await asyncio.gather(task, return_exceptions=True)
|