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:
+132
-8
@@ -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/"):
|
||||
|
||||
Reference in New Issue
Block a user