Skip to content

Client escalates handler-level HTTP errors to permanent connection failure #1117

Description

@JAORMX

Version: v1.7.0-pre.3

The streamable client maps HTTP status to transport health without distinguishing a transport-level rejection from an application-level error that a handler produced. Two spec-mandated server responses therefore kill the whole client session rather than failing one call.

Case 1 — -32021 at HTTP 400 permanently fails the session

SEP-2575 "Missing Required Capabilities" requires that if processing a request needs a capability the client did not declare in clientCapabilities, the server return a JSON-RPC error, and "For HTTP, the response status code MUST be 400 Bad Request". That is execution-time language, and the SDK's own server agrees: extractErrorStatus (streamable.go:1033-1061) maps -32021 → 400 for protocol ≥ 2026-07-28, including for a handler-produced response to tools/call.

On the client side that response is fatal to the connection:

  1. isTransientHTTPStatus (streamable.go:2750-2760) covers only 500, 502, 503, 504 and 429 — 400 is not transient.
  2. checkResponse (streamable.go:2525-2537) sees a non-404, non-transient status, decodes the body, surfaces the JSON-RPC error, and returns a plain error — not wrapped in jsonrpc2.ErrRejected.
  3. write (streamable.go:2306-2321) has requestMethod == "tools/call" (not methodDiscover) and a non-ErrRejected error, so it falls through to c.fail(err).
  4. fail (streamable.go:2176-2183) does failOnce.Do + close(c.failed) — one-shot and irreversible.

So a conformant server answering one tools/call with the spec's mandated status tears down the session. Every later request fails with the stored failure. A server author's only way to keep the reference client alive is to deviate from the MUST and return HTTP 200 with the same body.

Case 2 — the same shape via 404, affecting any unimplemented Modern method

SEP-2575 requires HTTP 404 for method-not-found. The 2025-11-25 streamable transport also requires 404 for a terminated session — quoted at streamable.go:2510-2512. The client cannot distinguish the two and takes the session reading: ErrSessionMissing, which is not wrapped in ErrRejected, so write again reaches c.fail() (streamable.go:2317-2321).

Consequence: a server that does not implement any one Modern method drops the session at Connect time rather than returning a per-call error. We hit this concretely with subscriptions/listen — a server without it cannot complete Connect at all when the client has notification handlers registered — but the mechanism is general, so it applies to every method a server has not implemented yet.

Why these belong together

Both are the same root cause: HTTP status is being read as connection state, while SEP-2575 assigns status codes by error code — an application-level classification. Any status the spec attaches to a per-request error condition will be misread as transport death.

Suggested direction

Distinguish transport-level failures from responses that carry a decodable JSON-RPC error. Concretely: if checkResponse successfully decodes a JSON-RPC error body, treat it as a per-call rejection (ErrRejected) regardless of status, and reserve c.fail() for genuine transport faults. For case 2, -32601 in a decodable body would then be distinguishable from a bare 404 with no JSON-RPC payload, which is what a terminated session actually produces.

Context

Found while implementing the 2026-07-28 client-facing surface in a stateless aggregating proxy built on this SDK. It is currently shipping HTTP 200 with a -32021 body — a deliberate, documented deviation from SEP-2575's status MUST — specifically because 400 would kill the session. We would rather be conformant.

Related: #1112, #1113, #1116, all from adopting v1.7.0-pre.3 in the same shim. Happy to supply a standalone repro (a server returning -32021/400 on tools/call, plus a client issuing a second request afterwards) or to send a PR if you'd like a particular shape.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions