Skip to content

rpc: JSON-RPC notifications, ids, statuses and the server timeout (#664) - #717

Open
bkeroack wants to merge 4 commits into
fix/671-rpc-parity-remainderfrom
fix/664-compat-notifications-timeouts
Open

rpc: JSON-RPC notifications, ids, statuses and the server timeout (#664)#717
bkeroack wants to merge 4 commits into
fix/671-rpc-parity-remainderfrom
fix/664-compat-notifications-timeouts

Conversation

@bkeroack

@bkeroack bkeroack commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Closes #664.

Seventh in the stack, based on #716. Merge order: #709#710#711#712#713#716 → this. (#715 is independent and unblocks CI.)

A 2.0 request with no id is a notification

The method runs and nothing comes back: HTTP 204 for a single one, and no entry in a batch. satd's compatibility layer injected "id": null into every request that lacked one — jsonrpsee will not run a request without an id — so it never saw a notification and the node always answered.

Each notification now gets a distinguishable synthetic id, and its reply is recognised and dropped. A shared null would not do: two notifications in one batch would come back indistinguishable.

id is echoed as Core echoes it

Core parses id as an optional and pushes it into the reply only when the request carried one (rpc/request.cpp, JSONRPCReplyObj). So "id": null and no id are different requests with different replies — the first is echoed null, the second gets no id member at all.

satd omitted every null id, which collapsed the two, and got the no-id case wrong for 1.0 clients. A second sentinel carries that distinction from the request, where it is knowable, to the response, where it is not.

The HTTP status carries the error class

Core's httprpc.cpp maps a parse error to 500, an invalid request to 400 and an unknown method to 404. jsonrpsee answers 200 to all three, so a client that switches on the status — Core's own interface_rpc.py does — could not tell them apart. An application error keeps 200, as Core does, and a batch is unaffected.

-rpcservertimeout bounded one phase of one listener

It set hyper's header-read timeout on the plain HTTP/1.1 path and nothing else. A client that sent a complete head and then no body, or held an idle keep-alive connection open, or spoke HTTP/2, was never disconnected — and the TLS listener used jsonrpsee's serve_with_graceful_shutdown, which takes no timeout at all, so a TLS connection had none.

serve_http_connection is generic over the transport now, so both surfaces use it, and the budget applies to keep-alive and to h2 as well.

A very large response is no longer DOM-parsed

Normalising a reply to JSON-RPC 1.0 means parsing it to touch three top-level keys, which costs several times its size again; a getblock at verbosity 2 over a full block is the realistic case. Past the body cap the reply is forwarded in jsonrpsee's shape instead. Truncating or refusing it would be worse: the answer is correct JSON, only its envelope is 2.0.

Verification

Three cargo gates green. Every guard perturbation-proved by a named failing test: notification detection, the response-id strip, the old omit-every-null rule, keeping notification replies in a batch, and the status mapping.

Four unit tests and three regtest tests over the wire — a lone notification is 204 with an empty body while the same request with an id answers normally; a 1.0 request without an id is answered with no id member and an explicit null is echoed; a batch drops only the notification; and the three status codes.

interface_rpc.py was re-measured: notifications, batch entries, id echoing and the status mapping all pass now, and the remaining failure is test_getrpcinfo asserting one entry in active_commands, which satd still reports as []. That is #702's, and the row's note names it.

🤖 Generated with Claude Code

https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL

@bkeroack

bkeroack commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Pushed one more commit (5bc1fdba): the HTTP status mapping applies to legacy requests only.

The Core-functional run set caught it — rpc_generate.py and wallet_disable.py both regressed to failing, and neither this branch's unit tests nor its regtest tests noticed. Core maps a JSON-RPC error code onto an HTTP status only for a 1.0/1.1/absent-jsonrpc request: HTTPReq_JSONRPC runs a 2.0 request with catch_errors{jreq.m_json_version == JSONRPCVersion::V2} and answers HTTP 200 with the error in the body, and JSONErrorReply — where the mapping lives — opens with Assume(jreq.m_json_version != JSONRPCVersion::V2).

Applying it to 2.0 as well is not cosmetic: Core's own authproxy raises -342 non-200 HTTP status code for a 2.0 reply with a non-200 status before it reads the error object, so the real code never reaches the caller. Both those tests assert on -32601 through exactly that path.

core_http_status now takes the request's version so the rule is testable; the_http_status_follows_cores_mapping asserts both halves. Full run set back to 40/40.

🤖 Generated with Claude Code

https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL

@bkeroack

bkeroack commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

One more commit (b4493af0): the regtest test written alongside the status mapping was asserting the behaviour 5bc1fdba corrected.

the_http_status_carries_the_jsonrpc_error_class sent a JSON-RPC 2.0 request and expected 404. It passed only because the mapping was being applied to 2.0 as well — and I did not re-run the suite after fixing that, so the stale assertion survived until the next branch's gates caught it. It now pins both halves: a legacy request carries the class in the status; a 2.0 request keeps 200 and carries the code in the body.

🤖 Generated with Claude Code

https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL

@bkeroack
bkeroack force-pushed the fix/671-rpc-parity-remainder branch from 7aa92b3 to 6bd870a Compare September 9, 2026 22:31
@bkeroack
bkeroack force-pushed the fix/664-compat-notifications-timeouts branch from b4493af to 97b9fff Compare September 9, 2026 22:31
@bkeroack
bkeroack force-pushed the fix/671-rpc-parity-remainder branch from 6bd870a to eeb1f01 Compare September 9, 2026 22:50
@bkeroack
bkeroack force-pushed the fix/664-compat-notifications-timeouts branch from 97b9fff to 40f5653 Compare September 9, 2026 22:50
bkeroack and others added 4 commits September 9, 2026 18:32
A JSON-RPC 2.0 request with no `id` is a notification: the method runs and
nothing comes back — 204 for a single one, no entry in a batch. satd's
compatibility layer injected `"id": null` into every request that lacked
one, because jsonrpsee will not run a request without an id, so it never
saw a notification and the node always answered. Each notification now
gets a distinguishable synthetic id whose reply is recognised and dropped.

The `id` is echoed as Core echoes it. Core parses it as an optional and
pushes it into the reply only when the request carried one, so `"id":
null` and no id are different requests with different replies. satd
omitted every null id, collapsing the two — and got the no-id case wrong
for 1.0 clients, which Core answers with no `id` member at all rather
than a null one. A second sentinel carries that distinction from the
request, where it is knowable, to the response, where it is not.

The HTTP status carries the error class. Core's `httprpc.cpp` maps a
parse error to 500, an invalid request to 400 and an unknown method to
404; jsonrpsee answers 200 to all three, so a client that switches on the
status could not tell them apart.

`-rpcservertimeout` bounded one phase of one listener: hyper's
header-read timeout on the plain HTTP/1.1 path and nothing else. A client
that sent a complete head and then no body, or held an idle keep-alive
connection, or spoke HTTP/2, was never disconnected — and the TLS
listener used jsonrpsee's helper, which takes no timeout, so a TLS
connection had none at all. The connection server is generic over the
transport now and both surfaces use it.

Finally, a response too large to normalise is forwarded in jsonrpsee's
shape rather than DOM-parsed to touch three top-level keys, which costs
several times its size again. A verbosity-2 `getblock` over a full block
is the realistic case; truncating or refusing it would be worse.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL
Caught by the Core-functional run set, not by this branch's own tests:
`rpc_generate.py` and `wallet_disable.py` both regressed to failing.

Core maps a JSON-RPC error code onto an HTTP status only for a legacy
(1.0 / 1.1 / absent-`jsonrpc`) request. `HTTPReq_JSONRPC` runs a 2.0
request with `catch_errors{jreq.m_json_version == JSONRPCVersion::V2}`
and replies HTTP 200 with the error in the body; `JSONErrorReply`, the
only place the mapping lives, opens with
`Assume(jreq.m_json_version != JSONRPCVersion::V2)`.

Applying it to 2.0 as well is not cosmetic. Core's own `authproxy`
raises `-342 non-200 HTTP status code` for a 2.0 reply whose status is
not 200 *before* it looks at the error object, so the real code never
reaches the caller — which is exactly how those two tests broke, both
of them asserting on `-32601`.

`core_http_status` now takes the request's version so the rule is
testable, and the existing test asserts both halves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL
)

The regtest test written alongside the HTTP status mapping sent a
JSON-RPC **2.0** request and asserted 404. It was passing because the
mapping applied to 2.0 too, which is the defect `5bc1fdba` fixed — and
I did not re-run the suite after that fix, so the stale assertion
survived until the next branch's gates caught it.

It now pins both halves: a legacy request carries the class in the
status, a 2.0 request keeps 200 and carries the code in the body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL
…s it

`JSONErrorReply` (`httprpc.cpp`) starts from `HTTP_INTERNAL_SERVER_ERROR`
and overrides exactly two codes: -32600 → 400 and -32601 → 404. satd mapped
those plus the parse error and left every application error — -8, -5,
-32602 — at 200, so `getblockhash [99999999]` on a 1.0 request answered
200 where Core answers 500; `interface_rpc.py` asserts that call. The
regtest test pinned the wrong behaviour and is corrected.

The mapping parses the reply, so it now stops at the same cap the 1.0
normalisation does: a reply past the cap is a result, not an error object,
and parsing it for a status code was the DOM parse the cap exists to avoid.

Release-note wording on `-rpcservertimeout` corrected: it bounds idle
keep-alive and h2 as well as the header read, but not a request whose head
arrived and whose body never completes, nor a long-running handler.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL
@bkeroack
bkeroack force-pushed the fix/671-rpc-parity-remainder branch from eeb1f01 to 85be3a0 Compare September 10, 2026 00:42
@bkeroack
bkeroack force-pushed the fix/664-compat-notifications-timeouts branch from 40f5653 to 6faab7c Compare September 10, 2026 00:42
@bkeroack

Copy link
Copy Markdown
Contributor Author

Independent review pass — one fix landed on this branch.

  • The legacy-request HTTP status mapping only covered a subset of codes and let others through as 200. Core's JSONErrorReply starts every legacy error at 500 and overrides just two: -32600 → 400 and -32601 → 404. The mapping is now exhaustive, and it is skipped when the reply body exceeds the normalisation cap so a huge reply is never re-parsed. The regtest that asserted a 200 for getblockhash out of range now asserts 500 with -8 in the body.
  • The -rpcservertimeout doc claimed a guarantee the implementation does not give (a handler that has already started is not interrupted). The doc now says what happens.

Verified against Core v31.1 httprpc.cpp.

🤖 Generated with Claude Code

https://claude.ai/code/session_011H5HUWaezLTYJCZDpgaioL

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant