fix(helpers): catch client disconnect errors in response write path
Extract _safe_write() helper that wraps end_headers() + wfile.write() in try/except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError, TimeoutError, ssl.SSLError). Both j() and t() now use _safe_write() instead of raw wfile calls. Fixes cascading BrokenPipeError + SSL BAD_LENGTH crash when a client disconnects mid-response and the error handler tries to write a 500 status through the same broken socket.
This commit is contained in:
1 parent
5915d675a0
commit
2cee44b1b2
1 file changed
+24
-4
+24
-4
@@ -4,10 +4,23 @@ Hermes Web UI -- HTTP helper functions.
|
||||
import json as _json
|
||||
import os
|
||||
import re as _re
|
||||
import ssl
|
||||
from pathlib import Path
|
||||
from api.config import IMAGE_EXTS, MD_EXTS
|
||||
|
||||
|
||||
# Treat stalled/closed HTTP clients as normal disconnects. Long-lived SSE
|
||||
# connections often end this way when a browser tab sleeps, a phone switches
|
||||
# networks, or Tailscale leaves the socket half-closed.
|
||||
_CLIENT_DISCONNECT_ERRORS = (
|
||||
BrokenPipeError,
|
||||
ConnectionResetError,
|
||||
ConnectionAbortedError,
|
||||
TimeoutError,
|
||||
ssl.SSLError,
|
||||
)
|
||||
|
||||
|
||||
def require(body: dict, *fields) -> None:
|
||||
"""Phase D: Validate required fields. Raises ValueError with clean message."""
|
||||
missing = [f for f in fields if not body.get(f) and body.get(f) != 0]
|
||||
@@ -65,6 +78,15 @@ def _accepts_gzip(handler) -> bool:
|
||||
return 'gzip' in ae
|
||||
|
||||
|
||||
def _safe_write(handler, body: bytes) -> None:
|
||||
"""Write response body, silently ignoring client disconnect errors."""
|
||||
try:
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
except _CLIENT_DISCONNECT_ERRORS:
|
||||
pass
|
||||
|
||||
|
||||
def j(handler, payload, status: int=200, extra_headers: dict=None) -> None:
|
||||
"""Send a JSON response.
|
||||
|
||||
@@ -89,8 +111,7 @@ def j(handler, payload, status: int=200, extra_headers: dict=None) -> None:
|
||||
if extra_headers:
|
||||
for k, v in extra_headers.items():
|
||||
handler.send_header(k, v)
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
_safe_write(handler, body)
|
||||
|
||||
|
||||
def t(handler, payload, status: int=200, content_type: str='text/plain; charset=utf-8') -> None:
|
||||
@@ -101,8 +122,7 @@ def t(handler, payload, status: int=200, content_type: str='text/plain; charset=
|
||||
handler.send_header('Content-Length', str(len(body)))
|
||||
handler.send_header('Cache-Control', 'no-store')
|
||||
_security_headers(handler)
|
||||
handler.end_headers()
|
||||
handler.wfile.write(body)
|
||||
_safe_write(handler, body)
|
||||
|
||||
|
||||
MAX_BODY_BYTES = 20 * 1024 * 1024 # 20MB limit for non-upload POST bodies
|
||||
|
||||
Reference in new issue
Block a user