1
0
mirror of https://github.com/sredevopsorg/deepseek-kubernetes-sandbox.git synced 2026-11-03 01:13:24 +00:00

fix(gateway): answer client-aborted requests with 499, not a traceback (#32)

* fix(gateway): answer client-aborted requests with 499, not a traceback

A browser reload, back navigation or proxy timeout can abort a request while
its body is still arriving. Starlette then raises ClientDisconnect from
request.stream(); nothing caught it, so ServerErrorMiddleware logged a full
ERROR traceback per aborted request and buried real faults. The request never
reaches DSH (the body is buffered before the upstream send) and the peer can
no longer be answered, so this is not an error.

- Register a ClientDisconnect exception handler that answers 499
  (CLIENT_CLOSED_REQUEST). One handler covers the proxy and the body-reading
  control routes (signup, login). A request already streaming keeps its
  upstream status because the response has started.
- Make _release await-free, and reach it even when the upstream close is
  cancelled. CancelledError is a BaseException, so suppress(Exception) around
  upstream.aclose() skipped the release and stranded the in-flight slot,
  leaving that user's sandbox looking busy forever (the idle sweeper then
  never suspends it).
- Add raw-ASGI regression tests: mid-body abort on the proxy and on signup,
  mid-stream abort, a cancelled release, and a cancelled upstream close.
- Document the 499 semantics in the gateway README failure table and the
  architecture failure modes.

Verified: make check, make test (114 passed), make lint. Against the pinned
uvicorn 0.54 the abort reproduces the original traceback before the fix and
logs no traceback after it.

* chore(deps): bump gateway starlette to 1.10.0 and ruff to 0.17.2

Absorbs Dependabot PR #27 against current main: that branch predates the
dsh-v0.2.1-rc.5 changelog/release commits, so merging it as-is would have
reverted them.

starlette 1.10.0 reworks URL-encoded form parsing (fields are built in the
parser callbacks, and the last field is kept when the stream ends without an
empty chunk) and clears tracebacks of handled exceptions. The gateway reads
JSON and form bodies, so the existing suite exercises signup/login forms and
the 499 client-abort path against the new release. ruff 0.17.2 only adds
preview and fix-safety changes; the configured rule set reports nothing new.

Verified: make check, make test (114 passed), make lint, make gateway (image
builds on python:3.12-slim and imports starlette 1.10.0), plus a direct ASGI
abort probe under 1.10.0 (499, in-flight slot released, nothing forwarded).

---------

Co-authored-by: SREDevOpsAgents <agents@sredevops.org>
This commit is contained in:
SREDevOps.org Agent
2026-10-30 07:14:49 -03:00
committed by GitHub
parent 8c34f43789
commit cbcc56f97e
6 changed files with 249 additions and 9 deletions

View File

@@ -62,7 +62,7 @@ from kubernetes import config as k8s_config
from kubernetes.client.exceptions import ApiException
from starlette.applications import Starlette
from starlette.datastructures import Headers
from starlette.requests import Request
from starlette.requests import ClientDisconnect, Request
from starlette.responses import (
HTMLResponse,
JSONResponse,
@@ -144,6 +144,11 @@ class UpstreamError(Exception):
"""The upstream DSH connection failed."""
# nginx's "client closed request": the peer went away before it could be
# answered, so no status is ever delivered.
CLIENT_CLOSED_REQUEST = 499
# --- Pure helpers (unit-testable without an app) --------------------------
@@ -682,9 +687,14 @@ class Gateway:
await asyncio.sleep(0.1)
async def _release(self, user: str) -> None:
async with self.state.lock:
self.state.in_flight[user] = max(self.state.in_flight.get(user, 1) - 1, 0)
self.state.last_activity[user] = time.time()
# Deliberately await-free: this runs from ``finally`` blocks, and the
# slot must be freed even when a client disconnect cancels the request
# mid-stream. Awaiting the lock there can be interrupted before the
# decrement, stranding the slot (the user's sandbox would then never
# look idle again). A plain dict update is atomic because the event
# loop cannot switch tasks between two synchronous statements.
self.state.in_flight[user] = max(self.state.in_flight.get(user, 1) - 1, 0)
self.state.last_activity[user] = time.time()
# -- sandbox / handshake helpers --------------------------------------
@@ -1238,9 +1248,17 @@ against the JSON API. The browser session is an HttpOnly cookie.</p>
print(f"stream for {user} ended early: {exc!r}", flush=True)
return
finally:
with contextlib.suppress(Exception):
await upstream.aclose()
await self._release(user)
# The slot must be freed even when this ``finally`` runs inside a
# cancelled task: on a client disconnect Starlette cancels the
# streaming task through an anyio cancel scope, and CancelledError
# is a BaseException, so ``suppress(Exception)`` would let a close
# that awaits abort the whole block before ``_release`` ran. A
# stranded slot keeps the user's sandbox looking busy forever.
try:
with contextlib.suppress(Exception):
await upstream.aclose()
finally:
await self._release(user)
# -- WebSocket proxy ---------------------------------------------------
@@ -1439,6 +1457,20 @@ def _lifespan(gateway: Gateway, close_http_client: httpx.AsyncClient | None = No
return lifespan
async def client_gone(request: Request, exc: ClientDisconnect) -> Response:
"""Answer a client that vanished mid-request instead of logging a traceback.
Reading a request body raises ``ClientDisconnect`` when a browser reload, a
back navigation or an ingress timeout aborts the upload. The request never
reached DSH, and nothing can be delivered to the peer that left, so the
honest outcome is 499 (nginx's "client closed request"). Letting it escape
would make Starlette's ``ServerErrorMiddleware`` log one ERROR traceback per
aborted request, which buries real faults. It also covers the control
routes that read a body (signup and login), not just the proxy.
"""
return Response(status_code=CLIENT_CLOSED_REQUEST)
def create_app(
settings: Settings | None = None,
k8s=None,
@@ -1471,6 +1503,7 @@ def create_app(
]
app = Starlette(
routes=routes,
exception_handlers={ClientDisconnect: client_gone},
lifespan=_lifespan(gateway, http_client if owns_http_client else None),
)
app.state.gateway = gateway