Stop answering cancelled requests (#3188)
This commit is contained in:
parent
e8ef138153
commit
923341c98a
3 changed files with 464 additions and 204 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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]],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue