router: HTTP/1.1 chunked Transfer-Encoding-Unterstützung

- Zentrale _read_body() Helper für Content-Length und chunked encoding
- _read_chunked_body(): hex Chunk-Größen, Chunk Extensions, 0-Chunk,
  Trailer, MAX_UPLOAD_SIZE (50 MB Default)
- Malformed chunked encoding → HTTP 400
- _transcribe(), _speech(), _image_generate(), _forward() nutzen Helper
- Temporäres STT-DEBUG-Logging entfernt
- 16 Regressionstests (dev/test_chunked.py):
  Content-Length, chunked, Multi-Chunk, Boundary über Chunk-Grenzen,
  Chunk Extensions, 0-Chunk mit Trailer, malformed Chunk Size,
  Uploadgrößenlimit, Open-WebUI-artiger WebM-Request
This commit is contained in:
Mikei386
2026-08-19 16:31:22 +02:00
parent e81f2ab515
commit ed471196d4
2 changed files with 566 additions and 8 deletions
+132 -8
View File
@@ -124,6 +124,10 @@ STT_TIMEOUT = float(os.environ.get("STT_TIMEOUT", "120")) # s, pro Transkriptio
STT_CONNECT_TIMEOUT = float(os.environ.get("STT_CONNECT_TIMEOUT", "5"))
STT_MODEL = "whisper-1" # virtuelles Modell für /v1/audio/transcriptions
# Maximale Upload-Größe (Bytes) – verhindert unbegrenzten RAM-Verbrauch.
# 50 MB ist für Audio-Dateien (WebM/Opus, WAV, MP3) mehr als ausreichend.
MAX_UPLOAD_SIZE = int(os.environ.get("MAX_UPLOAD_SIZE", 50 * 1024 * 1024))
# Chat-Waiting: Während eines Image-Jobs oder Profilwechsels ist Qwen
# down. Chat-Requests warten (statt 502) bis Qwen wieder bereit ist.
CHAT_WAIT_TIMEOUT = float(os.environ.get("CHAT_WAIT_TIMEOUT", "300")) # s, max. Warten
@@ -796,6 +800,108 @@ class Handler(BaseHTTPRequestHandler):
log.info("%s %s -> %s in %.3f s", self.command, path,
getattr(self, "_last_code", "-"), time.monotonic() - started)
# ---------- Request-Body-Lesen (Content-Length + chunked) ----------
def _read_body(self) -> bytes:
"""Liest den HTTP-Request-Body (Content-Length oder chunked).
Liefert die Body-Bytes. Wirft ValueError bei:
- malformed chunked encoding
- Upload größer als MAX_UPLOAD_SIZE
- unvollständiger Body
"""
te = self.headers.get("Transfer-Encoding", "").lower()
if "chunked" in te:
return self._read_chunked_body()
length = int(self.headers.get("Content-Length") or 0)
if length > MAX_UPLOAD_SIZE:
raise ValueError(
f"Upload zu groß: {length} bytes (max {MAX_UPLOAD_SIZE})")
if length == 0:
return b""
data = self.rfile.read(length)
if len(data) != length:
raise ValueError(
f"Unvollständiger Body: {len(data)}/{length} bytes")
return data
def _read_chunked_body(self) -> bytes:
"""Liest und dekodiert einen HTTP/1.1 chunked-Transfer-Encoding Body.
RFC 7230 §4.1:
chunked-body = *chunk last-chunk trailer-part CRLF
chunk = chunk-size [chunk-ext] CRLF chunk-data CRLF
chunk-size = 1*HEXDIG
last-chunk = 0 [chunk-ext] CRLF
trailer-part = *( field-line CRLF )
- Chunk-Größen werden hexadezimal geparst.
- Chunk Extensions (nach ';') werden toleriert/ignoriert.
- 0-Chunk markiert das Ende.
- Trailer werden konsumiert und ignoriert.
- MAX_UPLOAD_SIZE wird durchgesetzt.
"""
chunks: list[bytes] = []
total_size = 0
while True:
# Chunk-Size-zeile lesen: "hex-size [chunk-ext] CRLF"
size_line = self.rfile.readline(65537)
if not size_line:
raise ValueError("Chunked Body: unerwartetes Ende")
# CRLF/LF entfernen
size_line = size_line.rstrip(b"\r\n")
# Chunk Extension entfernen (alles nach dem ersten ';')
if b";" in size_line:
size_line = size_line.split(b";", 1)[0]
# Hexadezimale Größe parsen
size_str = size_line.strip()
if not size_str:
raise ValueError("Chunked Body: leere Chunk-Size")
try:
chunk_size = int(size_str, 16)
except ValueError:
raise ValueError(
f"Malformed Chunk-Size: {size_str!r}")
# 0-Chunk = Ende des chunked-body
if chunk_size == 0:
break
# Uploadgrößenlimit prüfen
total_size += chunk_size
if total_size > MAX_UPLOAD_SIZE:
raise ValueError(
f"Upload zu groß: {total_size} bytes "
f"(max {MAX_UPLOAD_SIZE})")
# Chunk-Daten lesen
chunk_data = self.rfile.read(chunk_size)
if len(chunk_data) != chunk_size:
raise ValueError(
f"Unvollständiges Chunk: {len(chunk_data)}/{chunk_size} bytes")
chunks.append(chunk_data)
# CRLF nach Chunk-Daten lesen
crlf = self.rfile.read(2)
if crlf != b"\r\n":
raise ValueError(
f"Erwartet CRLF nach Chunk, erhalten: {crlf!r}")
# Trailer lesen und ignorieren
# trailer-part = *( field-line CRLF ), beendet durch leere Zeile
while True:
line = self.rfile.readline(65537)
if not line or line in (b"\r\n", b"\n"):
break
# Trailer-Header ignorieren
return b"".join(chunks)
# ---------- Router-eigene Endpunkte ----------
@staticmethod
@@ -853,9 +959,14 @@ class Handler(BaseHTTPRequestHandler):
# ---------- Bildgenerierung ----------
def _image_generate(self) -> None:
length = int(self.headers.get("Content-Length") or 0)
try:
data = json.loads(self.rfile.read(length))
body = self._read_body()
except ValueError as e:
self._send_error(400, str(e),
"invalid_request_error", "invalid_body")
return
try:
data = json.loads(body)
except ValueError:
self._send_error(400, "ungültiges JSON",
"invalid_request_error", "invalid_json")
@@ -1016,9 +1127,14 @@ class Handler(BaseHTTPRequestHandler):
# ---------- Sprachausgabe (Kokoro) ----------
def _speech(self) -> None:
length = int(self.headers.get("Content-Length") or 0)
try:
data = json.loads(self.rfile.read(length))
body = self._read_body()
except ValueError as e:
self._send_error(400, str(e),
"invalid_request_error", "invalid_body")
return
try:
data = json.loads(body)
except ValueError:
self._send_error(400, "ungültiges JSON",
"invalid_request_error", "invalid_json")
@@ -1183,8 +1299,12 @@ class Handler(BaseHTTPRequestHandler):
"invalid_request_error", "invalid_content_type")
return
length = int(self.headers.get("Content-Length") or 0)
data = self.rfile.read(length)
try:
data = self._read_body()
except ValueError as e:
self._send_error(400, str(e),
"invalid_request_error", "invalid_body")
return
try:
file_data, filename, fields = self._parse_multipart(
@@ -1262,8 +1382,12 @@ class Handler(BaseHTTPRequestHandler):
# ---------- Transparentes Forwarding ----------
def _forward(self) -> None:
length = int(self.headers.get("Content-Length") or 0)
body = self.rfile.read(length) if length > 0 else None
try:
body = self._read_body() or None
except ValueError as e:
self._send_error(400, str(e),
"invalid_request_error", "invalid_body")
return
# Virtuelles Modell? -> Profil sicherstellen, dann Modell ersetzen.
if body is not None and self.path.startswith("/v1/"):