Stop answering cancelled requests (#3188)

This commit is contained in:
Max 2026-07-27 23:26:00 +01:00 • committed by GitHub
commit 923341c98a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 464 additions and 204 deletions

View file

@ -65,6 +65,13 @@ GET_STREAM_KEY = "_GET_stream"
# whole session on a lazily-started `sse_writer`. See #1764.
REQUEST_STREAM_BUFFER_SIZE: Final = 16
# Error code answering a request that settled without a response (e.g. it was
# cancelled) on this 2025-era wire, which ends a request's stream only with a
# response. Mirrors LSP's RequestCancelled; not sent by the 2026 transports, where
# the spec forbids answering a cancelled request. See
# `StreamableHTTPServerTransport._terminate_unanswered_request`.
REQUEST_CANCELLED: Final = -32800
# Session ID validation pattern (visible ASCII characters ranging from 0x21 to 0x7E)
# Pattern ensures entire string contains only valid characters by using ^ and $ anchors
SESSION_ID_PATTERN = re.compile(r"^[\x21-\x7E]+$")
@ -252,7 +259,7 @@ class StreamableHTTPServerTransport:
def _create_session_message(
self,
message: JSONRPCMessage,
message: JSONRPCRequest,
request: Request,
request_id: RequestId,
protocol_version: str,
@ -262,7 +269,10 @@ class StreamableHTTPServerTransport:
The close_sse_stream callbacks are only provided when the client supports
resumability (protocol version >= 2025-11-25). Old clients can't resume if
the stream is closed early because they didn't receive a priming event.
Every request carries `on_request_unanswered`, so a request that settles
without a response is still terminated on this era's wire.
"""
end_stream = partial(self._terminate_unanswered_request, message.id)
# Only provide close callbacks when client supports resumability
if self._event_store and is_version_at_least(protocol_version, "2025-11-25"):
@ -276,9 +286,10 @@ class StreamableHTTPServerTransport:
request_context=request,
close_sse_stream=close_stream_callback,
close_standalone_sse_stream=close_standalone_stream_callback,
on_request_unanswered=end_stream,
)
else:
metadata = ServerMessageMetadata(request_context=request)
metadata = ServerMessageMetadata(request_context=request, on_request_unanswered=end_stream)
return SessionMessage(message, metadata=metadata)
@ -390,6 +401,20 @@ class StreamableHTTPServerTransport:
return event_data
async def _terminate_unanswered_request(self, request_id: RequestId) -> None:
"""Terminate a request that settled without a response (e.g. cancelled).
The 2025-era wire ends a request's stream only with a response for its
id - and stores that response so a resuming client's replay terminates
too - so this era answers a cancelled request with `REQUEST_CANCELLED`
where the dispatcher itself stays silent (the 2026 transports MUST NOT
answer). It is written through the same ordered channel as the request's
other messages, so it cannot overtake anything already queued for it.
"""
assert self._write_stream is not None # a dispatched request implies connect() ran
error = ErrorData(code=REQUEST_CANCELLED, message="Request cancelled")
await self._write_stream.send(SessionMessage(JSONRPCError(jsonrpc="2.0", id=request_id, error=error)))
async def _clean_up_memory_streams(self, request_id: RequestId) -> None:
"""Clean up memory streams for a given request ID."""
if request_id in self._request_streams: # pragma: no branch
@ -555,7 +580,10 @@ class StreamableHTTPServerTransport:
)
request_stream_reader = self._request_streams[request_id][1]
# Process the message
metadata = ServerMessageMetadata(request_context=request)
metadata = ServerMessageMetadata(
request_context=request,
on_request_unanswered=partial(self._terminate_unanswered_request, message.id),
)
session_message = SessionMessage(message, metadata=metadata)
await writer.send(session_message)
try:

View file

@ -80,7 +80,9 @@ TransportT = TypeVar("TransportT", bound=TransportContext, default=TransportCont
PeerCancelMode = Literal["interrupt", "signal"]
"""How `notifications/cancelled` is applied: `"interrupt"` (default) cancels
the handler's scope; `"signal"` only sets `ctx.cancel_requested`."""
the handler's scope; `"signal"` only sets `ctx.cancel_requested` and lets the
handler run to completion. Either way the cancelled request is never
answered - the handler's eventual result or error is dropped, not written."""
def handler_exception_to_error_data(exc: BaseException) -> ErrorData | None:
@ -696,9 +698,13 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
) -> None:
"""Run `on_request` for one inbound request and write its response.
The single exception-to-wire boundary: handler exceptions become `JSONRPCError` here.
The single exception-to-wire boundary: handler exceptions become
`JSONRPCError` here. A request the peer cancelled is never answered
(spec: MUST NOT send further messages for it) - it settles unanswered
instead, and `_settle_unanswered` tells the transport.
"""
answer_write_started = False
handler_failure: BaseException | None = None # re-raised once the request settles
try:
with scope:
try:
@ -711,27 +717,21 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
key = coerce_request_id(req.id)
if (entry := self._in_flight.get(key)) is not None and entry.dctx is dctx:
del self._in_flight[key]
# A write interrupted by cancellation may still have delivered
# (a memory-stream send can hand its item to the receiver and
# still raise), so a started answer write counts as sent below:
# peers drop late responses, while a second answer for one id
# would break JSON-RPC.
answer_write_started = True
await self._write_result(req.id, result)
if scope.cancelled_caught:
# anyio absorbs the scope's own cancel at __exit__, and
# `cancelled_caught` (unlike `cancel_called`) guarantees the
# result write above did not happen - no double response.
# TODO(L38): spec says SHOULD NOT respond after cancel;
# the existing server always has, so match that for now.
answer_write_started = True
await self._write_error(req.id, ErrorData(code=0, message="Request cancelled"))
if not dctx.cancel_requested.is_set():
# A write interrupted by cancellation may still have delivered
# (a memory-stream send can hand its item to the receiver and
# still raise), so a started answer write counts as sent below:
# peers drop late responses, while a second answer for one id
# would break JSON-RPC.
answer_write_started = True
await self._write_result(req.id, result)
except anyio.get_cancelled_exc_class():
# Shutdown: answer the request so the peer isn't left waiting - unless
# an answer write already started (it may have reached the transport;
# prefer possibly-zero answers over possibly-two). The shielded helper
# is needed because bare awaits re-raise here.
if not answer_write_started:
# prefer possibly-zero answers over possibly-two), or the peer already
# cancelled it and stopped waiting. The shielded helper is needed
# because bare awaits re-raise here.
if not answer_write_started and not dctx.cancel_requested.is_set():
await self._final_write(
partial(self._write_error, req.id, ErrorData(code=CONNECTION_CLOSED, message="Connection closed")),
shield=True,
@ -741,15 +741,24 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
raise
except Exception as e:
error = handler_exception_to_error_data(e)
if error is not None:
await self._write_error(req.id, error)
else:
if error is None:
logger.exception("handler for %r raised", req.method)
# TODO(L58): code=0 pins existing-server compat; JSON-RPC says
# INTERNAL_ERROR. Revisit per the suite's divergence entry.
await self._write_error(req.id, ErrorData(code=0, message=str(e)))
error = ErrorData(code=0, message=str(e))
if self._raise_handler_exceptions:
raise
handler_failure = e
# A cancel silences only the wire; the failure stays as visible as before.
if not dctx.cancel_requested.is_set():
answer_write_started = True
await self._write_error(req.id, error)
# The one place a cancelled request settles: the handler is done (any
# mode) with nothing written. A peer-interrupt cancel is absorbed at
# scope __exit__ and lands here too.
if not answer_write_started:
await self._settle_unanswered(dctx)
if handler_failure is not None:
raise handler_failure
# No `_in_flight` pop here: the inner finally covers every path, and a late pop could evict a reused id.
def _allocate_id(self) -> int:
@ -771,6 +780,23 @@ class JSONRPCDispatcher(Dispatcher[TransportT]):
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
logger.debug("dropped error for %r: write stream closed", request_id)
async def _settle_unanswered(self, dctx: _JSONRPCDispatchContext[TransportT]) -> None:
"""Run the transport's `on_request_unanswered` hook: this request settled with no response.
The dispatcher writes nothing for it; a transport whose wire must still
end the request (2025-era streamable HTTP) does so from this hook. A
raising hook is contained here, like the other callback boundaries.
"""
metadata = dctx.message_metadata
if not isinstance(metadata, ServerMessageMetadata) or metadata.on_request_unanswered is None:
return
try:
await metadata.on_request_unanswered()
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
logger.debug("on_request_unanswered dropped: connection closing")
except Exception:
logger.exception("on_request_unanswered hook raised")
async def _final_write(
self,
write: Callable[[], Awaitable[None]],

View file

@ -41,6 +41,10 @@ class ServerMessageMetadata:
close_sse_stream: CloseSSEStreamCallback | None = None
# Callback to close the standalone GET SSE stream (for unsolicited notifications)
close_standalone_sse_stream: CloseSSEStreamCallback | None = None
# Callback the dispatcher runs when this request settles without a response
# (e.g. it was cancelled), for a transport whose wire must still end the
# request even though no response is written.
on_request_unanswered: Callable[[], Awaitable[None]] | None = None
MessageMetadata = ClientMessageMetadata | ServerMessageMetadata | None