mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-07 07:58:09 +00:00
* 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>
355 lines
12 KiB
Python
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}"
|