deer-flow/backend/tests/support/local_sockets.py
Hyeonsang Cho 48949482c5
fix(channels): give DingTalk AI cards the channel token, not the SDK fetch (#7597)
* fix(channels): give DingTalk AI cards the channel token, not the SDK fetch

AICardReplier's async card calls read dingtalk_client.get_access_token()
inline, and dingtalk-stream refreshes it with requests.post and no
timeout. Card creation and card streaming both run on the Gateway loop,
so the first card and every SDK refresh after it (about every two hours)
blocked the loop for the whole token request, without bound if DingTalk
stalled.

Hand the replier a _CardTokenClient that serves the channel's own
httpx token (10s timeout), set before card creation and before every
streamed update. A token failure while streaming now raises into send's
existing sampleMarkdown fallback instead of being skipped silently.

Pin it with a strict blocking-I/O anchor over a real DingTalkStreamClient
and AICardReplier, a gate meta-check, and unit tests for token rotation
and failure. LocalHttpServer also records each request's method, path
and headers.

* docs(changelog): reference #7597 for the DingTalk AI card token fetch

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
2026-10-28 10:50:30 +08:00

149 lines
5.9 KiB
Python

"""Loopback peers for exercising SDK transports at the socket boundary.
Both run on the calling test's event loop and only ever bind ``127.0.0.1:0``.
- ``StalledWebSocketServer`` completes the WebSocket opening handshake, then
reads and discards every frame without answering, so a client's close
handshake never gets its ACK. ``closed`` is set once the peer observes EOF,
i.e. once the client actually released the TCP connection.
- ``LocalHttpServer`` answers every request with a fixed status and JSON body,
or (``stall=True``) accepts the connection and never responds. Each request
body is recorded so tests can assert on what the SDK sent, and ``calls``
keeps each request's method, path and headers (names lowercased).
"""
from __future__ import annotations
import asyncio
import base64
import hashlib
import json
from collections.abc import Callable
from typing import Any
_WS_GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
async def wait_until(predicate: Callable[[], Any], *, timeout: float = 5.0, interval: float = 0.01) -> Any:
"""Poll ``predicate`` (which may read another thread's state) until it is truthy."""
loop = asyncio.get_running_loop()
deadline = loop.time() + timeout
while True:
value = predicate()
if value:
return value
if loop.time() >= deadline:
raise TimeoutError("condition not met in time")
await asyncio.sleep(interval)
def transport_socket(connection: Any) -> Any:
"""The socket object under a ``websockets`` connection, for release checks after stop."""
return connection.transport.get_extra_info("socket")
def _header(request: bytes, name: str) -> str:
prefix = name.lower() + ":"
for line in request.decode("latin-1").split("\r\n"):
if line.lower().startswith(prefix):
return line.split(":", 1)[1].strip()
return ""
class StalledWebSocketServer:
def __init__(self) -> None:
self.accepted = asyncio.Event()
self.closed = asyncio.Event()
self.port = 0
self._server: asyncio.base_events.Server | None = None
async def __aenter__(self) -> StalledWebSocketServer:
self._server = await asyncio.start_server(self._handle, "127.0.0.1", 0)
self.port = self._server.sockets[0].getsockname()[1]
return self
async def __aexit__(self, *exc_info: object) -> None:
assert self._server is not None
self._server.close()
try:
await asyncio.wait_for(self._server.wait_closed(), timeout=5)
except TimeoutError:
pass
@property
def url(self) -> str:
return f"ws://127.0.0.1:{self.port}/"
async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
request = await reader.readuntil(b"\r\n\r\n")
key = _header(request, "Sec-WebSocket-Key")
accept = base64.b64encode(hashlib.sha1((key + _WS_GUID).encode("ascii")).digest()).decode("ascii")
writer.write(("HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + f"Sec-WebSocket-Accept: {accept}\r\n\r\n").encode("ascii"))
await writer.drain()
self.accepted.set()
while await reader.read(65536):
pass # swallow pings, data and the close frame; never answer
except (asyncio.IncompleteReadError, ConnectionError):
pass
finally:
self.closed.set()
writer.close()
class LocalHttpServer:
def __init__(self, *, status: int = 200, body: Any = None, stall: bool = False) -> None:
self.status = status
self.body = body if body is not None else {}
self.stall = stall
self.requests: list[dict[str, Any]] = []
self.calls: list[tuple[str, str, dict[str, str]]] = []
self.accepted = asyncio.Event()
self.closed = asyncio.Event()
self.port = 0
self._server: asyncio.base_events.Server | None = None
async def __aenter__(self) -> LocalHttpServer:
self._server = await asyncio.start_server(self._handle, "127.0.0.1", 0)
self.port = self._server.sockets[0].getsockname()[1]
return self
async def __aexit__(self, *exc_info: object) -> None:
assert self._server is not None
self._server.close()
try:
await asyncio.wait_for(self._server.wait_closed(), timeout=5)
except TimeoutError:
pass
@property
def url(self) -> str:
return f"http://127.0.0.1:{self.port}/open"
async def _handle(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
try:
head = await reader.readuntil(b"\r\n\r\n")
length = int(_header(head, "Content-Length") or 0)
raw = await reader.readexactly(length) if length else b""
request_line, *header_lines = head.decode("latin-1").split("\r\n")
method, path, _ = request_line.split(" ", 2)
headers = {name.strip().lower(): value.strip() for name, sep, value in (line.partition(":") for line in header_lines) if sep}
self.calls.append((method, path, headers))
try:
self.requests.append(json.loads(raw) if raw else {})
except ValueError:
self.requests.append({"raw": raw.decode("latin-1")})
self.accepted.set()
if self.stall:
while await reader.read(65536):
pass # hold the request open until the client gives up
return
payload = json.dumps(self.body).encode("utf-8")
writer.write((f"HTTP/1.1 {self.status} X\r\nContent-Type: application/json\r\nContent-Length: {len(payload)}\r\nConnection: close\r\n\r\n").encode("ascii") + payload)
await writer.drain()
except (asyncio.IncompleteReadError, ConnectionError):
pass
finally:
self.closed.set()
writer.close()