litellm/tests/integration/_support/wire.py
tessellark f357872c98
test(integration): stop the wire peer from dropping requests whose bytes arrive late (#49889)
* test(integration): stop the wire peer from dropping requests whose bytes arrive late

The scripted wire peer set a fixed 5 s socket timeout, so a request whose
bytes reached it late on a loaded rig was dropped with no reply and the
proxy answered 500 Server disconnected. Remove the timeout and, at
teardown, stop reading on every accepted connection so idle handlers exit
at once. A request still arriving when teardown starts never reaches the
test's respond callback.

* fix(tests): bound wire peer teardown on unread replies and answer complete requests

* fix(tests): poll wire peer reads so teardown keeps queued requests and only cuts stuck calls

* fix(tests): write wire peer replies in pieces so teardown never cuts a client still reading

* fix(tests): give a wire peer reply the whole teardown grace when its write blocked before teardown

---------

Co-authored-by: mateo-berri <277851410+mateo-berri@users.noreply.github.com>
2026-11-05 09:24:05 -08:00

355 lines
12 KiB
Python

from __future__ import annotations
import io
import selectors
import socket
import ssl
import threading
import time
from collections.abc import Callable, Generator, Iterator, Mapping
from contextlib import contextmanager, suppress
from dataclasses import dataclass
from functools import partial
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from queue import SimpleQueue
from types import MappingProxyType
from typing import TYPE_CHECKING, BinaryIO, Final
if TYPE_CHECKING:
from collections.abc import Buffer
_POLL_SECONDS: Final = 0.05
_WRITE_PIECE_BYTES: Final = 64 * 1024
@dataclass(frozen=True, slots=True)
class Request:
method: str
target: str
headers: Mapping[str, str]
body: bytes
header_lines: tuple[tuple[str, str], ...] = ()
class AbortedBody(Exception):
"""The client closed the connection before the body it announced was complete."""
def exactly(stream: BinaryIO, size: int) -> bytes:
data: Final = stream.read(size)
if len(data) < size:
raise AbortedBody
return data
def chunked_body(stream: BinaryIO) -> Iterator[bytes]:
while True:
size_line: Final = stream.readline()
if not size_line:
raise AbortedBody
size: Final = int(size_line.split(b";")[0].strip(), 16)
if size == 0:
while stream.readline().strip():
pass
return
yield exactly(stream, size)
stream.readline()
def read_body(headers: Mapping[str, str], stream: BinaryIO) -> bytes:
if headers.get("transfer-encoding", "").lower() == "chunked":
return b"".join(chunked_body(stream))
return exactly(stream, int(headers.get("content-length", "0")))
@dataclass(frozen=True, slots=True)
class Reply:
status: int = 200
body: bytes = b"{}"
content_type: str = "application/json"
chunks: tuple[bytes, ...] | None = None
abort_after: int | None = None
gate_before_first: threading.Event | None = None
gate_after_first: threading.Event | None = None
pause_between_chunks: float = 0
heartbeat: bytes | None = None
headers: Mapping[str, str] = MappingProxyType({})
drop_connection: bool = False
@dataclass(frozen=True, slots=True)
class Wire:
url: str
received: SimpleQueue[Request]
disconnected: SimpleQueue[str]
connected: SimpleQueue[str]
closed: SimpleQueue[str]
def drain(self) -> tuple[Request, ...]:
return tuple(self.received.get_nowait() for _ in range(self.received.qsize()))
def connections(self) -> int:
return self.connected.qsize()
def open_connections(self) -> int:
return self.connected.qsize() - self.closed.qsize()
def _await_release(
gate: threading.Event, closing: threading.Event, beat: Callable[[], None] | None = None, every: float = 0.05
) -> bool:
while not closing.is_set():
if gate.wait(timeout=every):
return True
if beat is not None:
beat()
return gate.is_set()
class _RequestReader(io.BufferedReader):
cut = False
def readline(self, size: int | None = -1, /) -> bytes:
line: Final = super().readline(size)
self.cut = self.cut or not line.endswith(b"\n")
return line
@dataclass(frozen=True, slots=True)
class _Blocked:
since: float
writing: bool
class _Connection(io.BufferedIOBase):
blocked: _Blocked | None = None
def __init__(self, connection: socket.socket, closing: threading.Event) -> None:
super().__init__()
self.connection = connection
self.closing = closing
self.selector = selectors.DefaultSelector()
self.selector.register(connection, selectors.EVENT_READ)
def readable(self) -> bool:
return True
def writable(self) -> bool:
return True
def readinto(self, buffer: Buffer, /) -> int:
if not self._wait_for_bytes():
return 0
with self._blocking(writing=False):
return self.connection.recv_into(buffer)
def write(self, buffer: Buffer, /) -> int:
view: Final = memoryview(buffer).cast("B")
for start in range(0, view.nbytes, _WRITE_PIECE_BYTES):
with self._blocking(writing=True):
self.connection.sendall(view[start : start + _WRITE_PIECE_BYTES])
return view.nbytes
def close(self) -> None:
self.selector.close()
super().close()
def stalled(self, grace: float, teardown_started: float) -> _Blocked | None:
blocked: Final = self.blocked
if blocked is None or time.monotonic() - max(blocked.since, teardown_started) < grace:
return None
return blocked
def shut(self) -> None:
with suppress(OSError):
self.connection.shutdown(socket.SHUT_RDWR)
def _wait_for_bytes(self) -> bool:
while not self.closing.is_set():
if self._has_bytes(_POLL_SECONDS):
return True
return self._has_bytes(0)
def _has_bytes(self, timeout: float) -> bool:
decrypted: Final = isinstance(self.connection, ssl.SSLSocket) and self.connection.pending() > 0
return decrypted or bool(self.selector.select(timeout))
@contextmanager
def _blocking(self, writing: bool) -> Iterator[None]:
self.blocked = _Blocked(time.monotonic(), writing)
try:
yield
finally:
self.blocked = None
def _close_unless_stalled(
close: Callable[[], None], connections: SimpleQueue[_Connection], grace: float, errors: SimpleQueue[Exception]
) -> None:
teardown_started: Final = time.monotonic()
closer: Final = threading.Thread(target=close)
closer.start()
while closer.is_alive():
for connection in tuple(connections.get_nowait() for _ in range(connections.qsize())):
stalled: Final = connection.stalled(grace, teardown_started)
if stalled is None:
connections.put(connection)
continue
if stalled.writing:
errors.put(TimeoutError(f"a reply write made no progress for {grace} s"))
connection.shut()
closer.join(timeout=_POLL_SECONDS)
@contextmanager
def wire_server(
respond: Callable[[Request], Reply],
tls: ssl.SSLContext | None = None,
port: int = 0,
keep_alive: bool = False,
teardown_grace: float = 10,
) -> Generator[Wire, None, None]:
"""Owned TCP peer; requests traverse the real HTTP client and serialization. With `keep_alive` the
peer honours HTTP/1.1 persistent connections so `connections()` counts the client's TCP sessions."""
received: Final[SimpleQueue[Request]] = SimpleQueue()
errors: Final[SimpleQueue[Exception]] = SimpleQueue()
disconnected: Final[SimpleQueue[str]] = SimpleQueue()
connected: Final[SimpleQueue[str]] = SimpleQueue()
closed: Final[SimpleQueue[str]] = SimpleQueue()
streams: Final[SimpleQueue[_Connection]] = SimpleQueue()
closing: Final = threading.Event()
class Handler(BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
request: socket.socket
reader: _RequestReader
def setup(self) -> None:
stream: Final = _Connection(self.request, closing)
self.reader = _RequestReader(stream)
self.rfile = self.reader
self.wfile = stream
streams.put(stream)
connected.put(f"{self.client_address[0]}:{self.client_address[1]}")
def finish(self) -> None:
super().finish()
closed.put(f"{self.client_address[0]}:{self.client_address[1]}")
def write_chunk(self, chunk: bytes) -> None:
self.wfile.write(b"%x\r\n%s\r\n" % (len(chunk), chunk))
self.wfile.flush()
def respond(self) -> None:
if self.reader.cut:
self.close_connection = True
return
headers: Final = {name.lower(): value for name, value in self.headers.items()}
try:
body: Final = read_body(headers, self.rfile)
except AbortedBody:
self.close_connection = True
disconnected.put(self.path)
return
request: Final = Request(self.command, self.path, headers, body, tuple(self.headers.items()))
received.put(request)
try:
reply = respond(request)
except Exception as error:
errors.put(error)
reply = Reply(status=500)
if reply.drop_connection:
self.close_connection = True
disconnected.put(request.target)
return
self.send_response(reply.status)
self.send_header("content-type", reply.content_type)
for name, value in reply.headers.items():
self.send_header(name, value)
if reply.chunks is None:
self.send_header("content-length", str(len(reply.body)))
else:
self.send_header("transfer-encoding", "chunked")
if not keep_alive:
self.send_header("connection", "close")
self.end_headers()
try:
if self.command == "HEAD":
self.wfile.flush()
elif reply.chunks is None:
self.wfile.write(reply.body)
else:
for index, chunk in enumerate(reply.chunks):
if reply.abort_after == index:
break
if (
index == 0
and reply.gate_before_first is not None
and not _await_release(reply.gate_before_first, closing)
):
break
self.write_chunk(chunk)
if (
index == 0
and reply.gate_after_first is not None
and not (
_await_release(reply.gate_after_first, closing)
if reply.heartbeat is None
else _await_release(
reply.gate_after_first,
closing,
partial(self.write_chunk, reply.heartbeat),
reply.pause_between_chunks,
)
)
):
break
if reply.pause_between_chunks and index + 1 < len(reply.chunks):
time.sleep(reply.pause_between_chunks)
else:
self.wfile.write(b"0\r\n\r\n")
self.wfile.flush()
except (BrokenPipeError, ConnectionResetError):
disconnected.put(request.target)
except Exception as error:
errors.put(error)
self.close_connection = not keep_alive
do_POST = respond
do_PUT = respond
do_GET = respond
do_DELETE = respond
do_PATCH = respond
do_HEAD = respond
def log_message(self, format: str, *args: object) -> None:
pass
class OwnedHTTPServer(ThreadingHTTPServer):
daemon_threads = False
request_queue_size = 128
def server_bind(self) -> None:
super().server_bind()
if tls is not None:
self.socket = tls.wrap_socket(self.socket, server_side=True)
with OwnedHTTPServer(("127.0.0.1", port), Handler) as server:
thread: Final = threading.Thread(target=server.serve_forever, kwargs={"poll_interval": _POLL_SECONDS})
thread.start()
try:
yield Wire(
f"{'https' if tls is not None else 'http'}://127.0.0.1:{server.server_port}",
received,
disconnected,
connected,
closed,
)
finally:
closing.set()
server.shutdown()
thread.join(timeout=6)
assert not thread.is_alive(), "Owned HTTP server survived cleanup"
_close_unless_stalled(server.server_close, streams, teardown_grace, errors)
failure: Final = None if errors.empty() else errors.get_nowait()
assert failure is None, f"Owned HTTP peer failed: {failure!r}"