Skip to content

[Feature] Standardize JSON-RPC error mapping and exception boundaries #6941

Description

@waynercheung

Summary

For an exception without an @JsonRpcErrors mapping, JSON-RPC falls back to jsonrpc4j's -32001 and returns the Java exception class name and the raw exception message to the client, for example:

{"jsonrpc":"2.0","id":1,"error":{"code":-32001,"message":null,"data":"java.lang.NullPointerException"}}

This issue standardizes JSON-RPC error mapping and the exception boundaries:

  • Unmapped non-fatal exceptions return -32603 "Internal error"; Java exception class names and raw messages are no longer echoed.
  • Four fatal Error categories, including java-tron's TronError, propagate instead of being disguised as JSON-RPC error responses. Before rethrowing, the servlet makes a best-effort attempt to commit an empty HTTP 500 so the container does not render exception details.
  • An invalid request ID type or non-null scalar params returns -32600 "Invalid Request" instead of being swallowed on the single-request path or aborting the whole batch.

Only failure-path responses change. Whenever a normal JSON-RPC response is produced, its HTTP status remains 200; before propagating a fatal Error, the servlet best-effort commits an empty HTTP 500, while a failed attempt may leave a closed connection. Successful responses, gRPC and non-JSON-RPC HTTP API behavior remain unchanged.

This issue only covers the framework-level fallback and exception boundaries; it does not change how any method validates its own parameters. The null parameter of eth_getLogs in the example is a separate problem; even once it gets a null check, any other unmapped exception still takes this path.

Problem

Motivation

  • message: null violates JSON-RPC 2.0 section 5.1, which defines message as "A String providing a short description of the error" (null is not a String); on Java 17 it becomes a diagnostic string containing internal method signatures; data echoes the Java exception class name. None of these should be depended on by clients.
  • -32001 is registered in the public error catalog as a server-side internal error, yet the fallback files every unmapped exception there, including client input errors, so clients cannot tell them apart.
  • OutOfMemoryError / StackOverflowError are converted into ordinary error responses, masking an unrecoverable process state.
  • A single request with an invalid request ID gets HTTP 200 with an empty body. Scalar params has the same result after a registered method reaches argument matching; an unknown method is rejected earlier as -32601. In a batch, the servlet catch-all returns only a -32603 / id: null response for the framework exception and stops early, discarding prior results and skipping later elements.

Current State

  • Unmapped exceptions: the resolver returns null and jsonrpc4j falls back to ERROR_NOT_HANDLED (-32001). net_version / eth_chainId declare JsonRpcInternalException without an annotation and take exactly this path.
  • The asynchronous query in eth_getLogs / eth_getFilterLogs: ExecutionException leaks the cause's type through message; InterruptedException yields message: null and leaves the interrupt flag cleared.
  • Fatal errors: jsonrpc4j catches Throwable both at the method invocation layer and in handle(...).
  • Protocol level: a Boolean / object / array request ID throws IllegalArgumentException. Scalar params also throws after a registered method reaches argument matching; an unknown method returns -32601 before that check. Single-request handle(...) swallows the exception, while the batch servlet catch-all returns -32603 and stops early.
  • Unmapped exceptions at the method invocation stage leave no trace on the node (setShouldLogInvocationErrors(false)).

develop @ 4a21592 and GreatVoyage-v4.8.2.1 are both affected; verified on Java 8 and Java 17. Reproduce:

# unmapped exception (any unmapped exception gives the same shape)
curl -s -X POST http://127.0.0.1:8545/jsonrpc -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","method":"eth_getLogs","params":[null],"id":1}'
# invalid request ID type -> HTTP 200 with an empty body
curl -i -s -X POST http://127.0.0.1:8545/jsonrpc -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","method":"web3_clientVersion","params":[],"id":true}'
# scalar params -> HTTP 200 with an empty body
curl -i -s -X POST http://127.0.0.1:8545/jsonrpc -H 'Content-Type: application/json' \
  -d '{"jsonrpc":"2.0","method":"web3_clientVersion","params":5,"id":2}'

Limitations and Risks

  • Clients matching the old message / data will observe a change; the net_version / eth_chainId responses are registered in the public error catalog and need a synchronized update.
  • When a fatal Error propagates, the client receives a best-effort empty HTTP 500 or a closed connection; the current batch loses accumulated results. This is an intentional boundary.
  • Consensus, chain state and funds are not involved.

Proposed Solution

Proposed Design

Case HTTP status JSON-RPC code message data / notes
Unmapped non-fatal exception 200 -32603 "Internal error" no data; first (method, exception type) occurrence is WARN with the stack, repeats are DEBUG without stack/message
net_version / eth_chainId failure 200 -32001 "Chain identity unavailable" "{}" (explicit mapping; keeps the code registered in the public catalog)
ExecutionException / InterruptedException 200 -32000 "Internal error" "{}"; InterruptedException restores the interrupt flag
Fatal Error (VirtualMachineError / ThreadDeath / LinkageError / TronError, including wrapped ones) no JSON-RPC response N/A N/A servlet best-effort commits an empty HTTP 500 and rethrows the same Error; resource exhaustion may close the connection instead; propagation does not itself terminate the process
handleRequest throws IOException 200 -32603 when an ID is present "Internal error" no data; a no-ID single request remains response-free
Boolean / object / array request ID 200 -32600 "Invalid Request" id: null (2.0 section 4); in a batch only that element is affected
Non-null scalar params (single request or batch element) 200 -32600 "Invalid Request" no data; echo a valid id, otherwise use id: null; only the offending batch element is affected
Mapped errors 200 unchanged deliberate business messages such as "filter not found" unchanged "{}" unchanged

-32603 is the Internal error defined by JSON-RPC 2.0; its error-code classification matches Besu's RpcErrorType.INTERNAL_ERROR. Rejecting Boolean IDs is stricter than go-ethereum, following 2.0 section 4 (an ID is a String, Number or Null). Section 4.2 requires params to be structured. java-tron classifies non-null scalar params at the request-envelope layer as -32600, matching Besu's error-code classification (its HTTP status handling differs); geth classifies the same shape as -32602 at method-argument parsing. This is a difference in layering and error classification, not a claim that geth violates the specification.

Request-envelope validation takes precedence over method lookup: an unknown method with scalar params changes from -32601 to -32600, while the same unknown method with valid params: [] remains -32601. An object with scalar params and no id is not a valid Notification, because a Notification must first be a valid Request Object under sections 4 and 4.1; it therefore receives -32600 with id: null. Valid notifications still produce no response.

Key Changes

  • resolver: unmapped non-fatal exceptions become -32603; logging is bounded by (method, exception type), with the first occurrence at WARN carrying the Throwable and repeats at DEBUG without the Throwable/message; message precedence is annotation > exception > per-code default; walk the cause chain for four fatal Error categories and rethrow the actual cause (identity-set cycle protection).
  • mapping annotations: add an explicit -32001 mapping for net_version / eth_chainId; give ExecutionException / InterruptedException a fixed message; log the cause and restore the interrupt flag at Future.get().
  • servlet: dispatch single requests through handleRequest(InputStream, OutputStream) (handle(...) swallows fatal errors); map escaped RuntimeException | IOException; best-effort commit an empty 500 before rethrowing an escaped Error without allowing cleanup failures to replace it; validate request-ID and non-null params container types before dispatch and isolate batch elements.
  • The change is limited to the JSON-RPC layer of the framework module: JsonRpcErrorResolver, JsonRpcServlet, TronJsonRpc, TronJsonRpcImpl, LogBlockQuery.

Impact

  • Security: error responses no longer return Java exception class names or unaudited exception messages; propagating fatal errors stops masking an existing process-level failure signal.
  • Stability: unmapped exceptions get a stable fallback; a future method that misses a null check will not echo internal types.
  • Performance: the normal path is unaffected; full WARN stacks for unmapped exceptions are limited to the first occurrence of each method/type pair.
  • Developer Experience: errors become interpretable against the specification; unmapped exceptions at the method invocation stage start appearing in the node log.

Compatibility

Item Result
Breaking Change Yes, limited to failure and exception handling paths. Unmapped exceptions -32001 + class name -> -32603; net_version / eth_chainId keep the code but get a fixed message / data; ExecutionException / InterruptedException get a fixed message; invalid IDs, and scalar params after a registered method is selected, go from an empty body to an error response for single requests and from one -32603 plus early batch termination to an isolated -32600 for batch elements; an unknown method with scalar params changes from -32601 to -32600, while valid params: [] still returns -32601; four fatal categories go from an error response to an empty HTTP 500 or closed connection. Difference from geth: geth returns -32602 for scalar params and sends no response when such a malformed request has no id, whereas java-tron returns -32600 with id: null. Must be included in the release notes.
Default Behavior Change Yes. Only failure responses and request ID / non-null params container validation change; successful responses remain unchanged.
Migration Required Conditional. Clients matching the old message / data need to adjust.

The following remain unchanged: successful responses, HTTP 200 whenever a response is produced, existing dispatch and method validation for missing / null / Array / Object params, code / data of the 62 existing mappings (4 asynchronous-exception mappings only gain a message; 2 chain identity mappings are added), gRPC and non-JSON-RPC HTTP API behavior.

Before merge: update the public error catalog (docs/api/openrpc.json in documentation-en; four entries: JSON_RPC_UNDERLYING_INTERNAL_ERROR, JSON_RPC_SERVLET_INTERNAL_ERROR, JSON_RPC_EXECUTION_ERROR, JSON_RPC_INTERRUPTED); check whether gateways / SDKs / monitoring depend on the old -32001 behavior.

Acceptance Criteria

  • code / message / data for every row of the table are pinned by tests against a real JsonRpcServer / JsonRpcServlet.
  • All four fatal categories (including wrapped causes) escape the servlet; the best-effort empty 500 never echoes the fatal marker, and cleanup failure never replaces the original Error.
  • Escaped RuntimeException / IOException follows the documented ID/notification behavior.
  • Resolver logging emits one WARN per method/type pair; ethChainId() logs failure/recovery transitions; Future.get() logs the cause and restores the interrupt flag.
  • code / data of the 62 existing mappings are unchanged.
  • Whenever a response is produced, HTTP 200, content type, the empty response for valid notifications, and the batch and response size limits are unchanged.
  • Non-null scalar params returns -32600 "Invalid Request" for single and batch requests; a valid id is preserved and other batch elements continue.
  • Request-envelope validation precedes method lookup: unknown method + scalar params returns -32600, while the same unknown method + params: [] remains -32601.
  • A characterization test pins jsonrpc4j dispatch behavior so a later upgrade cannot drift silently.

Follow-up

Outside the scope of this issue and not blocking its closure:

Additional Notes

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions