Skip to content

fix(jsonrpc): normalize error responses and fatal handling - #6

Open
waynercheung wants to merge 1 commit into
developfrom
feat/jsonrpc-error-sanitization
Open

fix(jsonrpc): normalize error responses and fatal handling#6
waynercheung wants to merge 1 commit into
developfrom
feat/jsonrpc-error-sanitization

Conversation

@waynercheung

@waynercheung waynercheung commented Aug 29, 2026

Copy link
Copy Markdown
Owner

What does this PR do?

Replaces jsonrpc4j's unhandled-exception fallback with spec-defined responses and stops converting fatal errors into JSON-RPC replies.

Resolver (JsonRpcErrorResolver):

  • Unmapped non-fatal exceptions -> -32603 "Internal error" with no data. Logging is bounded by (RPC method, exception class): the first occurrence is WARN with the Throwable; repeats are DEBUG without the Throwable or exception message.
  • Mapped exceptions get a message precedence of annotation > exception message > per-code default (Invalid Request / Method not found / Invalid params / Internal error), so message is never null. data is exception data > annotation data; jsonrpc4j's ErrorData(exceptionClass, message) default is gone.
  • VirtualMachineError, ThreadDeath, LinkageError and java-tron's TronError found anywhere on the cause chain are rethrown as the actual cause instead of being answered. A shared allocation-free, cycle-safe scan has no cause-depth cutoff; servlet dispatch catches use it before ordinary logging or error mapping as well.
  • net_version / eth_chainId keep their documented -32001 through an explicit mapping ("Chain identity unavailable", data "{}"); ethChainId() keeps the cause and logs failure-state transitions (first failure WARN, recovery INFO, no repeated WARN during one outage).
  • ExecutionException / InterruptedException on the asynchronous log query get a fixed "Internal error" message; LogBlockQuery logs the cause and restores the interrupt flag.

Servlet (JsonRpcServlet):

  • Single requests go through handleRequest(InputStream, OutputStream) instead of handle(request, response), whose catch (Throwable) would swallow the rethrown fatal error. Single and batch dispatch inspect escaped RuntimeException or declared IOException for a fatal cause before ordinary error handling.
  • Recoverable batch failures in request serialization, dispatch or response parsing produce an element-specific -32603, retaining earlier results and continuing with later requests unless the existing response budget overflows. Each batch logs only its first escaped non-fatal dispatch failure at ERROR with the Throwable; subsequent failures use DEBUG with only the index and exception class. This state is request-local, and fatal inspection precedes logging. Malformed response bytes are discarded; only the replacement error is charged. The original pre-parse size check and strict > boundary remain: bytes exceeding the remaining budget get -32003 even if malformed.
  • An outer doPost guard best-effort commits a zero-length HTTP 500 before rethrowing an escaped Error. On this cleanup path only, it unwraps up to 16 ServletResponseWrapper layers because the production HttpInterceptor's response wrapper does not delegate flushBuffer. Self-reference, an unresolved/deeper chain or a non-HTTP inner response abandons cleanup without calling response methods through that wrapper. Cleanup failures never replace the original fatal. The client may receive an empty 500 or a closed connection; if cleanup cannot commit, container fallback remains possible. This propagation does not itself terminate the process.
  • HTTP 200 and application/json-rpc are set explicitly at normal JSON-RPC servlet exits; the custom HttpStatusCodeProvider configuration and the now-unused CachedBodyRequestWrapper are removed.
  • Request envelope types are validated before dispatch. Boolean / object / array IDs get -32600 "Invalid Request" with id: null. Non-null scalar params also gets -32600; a valid id is echoed, while a missing, null or invalid id becomes null. In a batch only the offending element gets the error and the other elements still execute. An explicit id: null on an otherwise valid request is not rejected by these checks; its final semantics, and whether to reject params: null, are decided with [Feature]Standardize JSON-RPC error handling(revert codes, LiteNode pruned-history responses, request fields validation) tronprotocol/java-tron#6676.
  • The single-request catch-all preserves a valid request id; when id is absent it keeps the existing empty 200. A recoverable batch failure without id still produces an error with id: null. Responses returned normally by jsonrpc4j are forwarded unchanged, including its existing error responses to some requests without id. Unifying notification response suppression is a separate pending decision, not part of these recovery fixes.

Why are these changes required?

An exception without an @JsonRpcErrors mapping currently produces (Java 8):

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

message: null violates JSON-RPC 2.0 section 5.1 (on Java 17 it becomes a helpful-NPE string echoing internal class and method names), data exposes internal types, and -32001 is the code the public error catalog documents for the chain identity lookup, so callers cannot tell their own bad input from a node failure. Fatal errors such as OutOfMemoryError, StackOverflowError and TronError were converted into ordinary error responses, masking a process-level failure; after propagation, the servlet now best-effort commits a detail-free 500 rather than allowing Jetty's default error page to render the Throwable. Invalid request ID types produced HTTP 200 with an empty body for single requests. Scalar params had the same result after a registered method reached argument matching; an unknown method returned -32601 before inspecting params. In a batch, framework exceptions produced only -32603 with id: null and stopped further processing.

-32603 is the Internal error defined by the specification and matches Besu's RpcErrorType.INTERNAL_ERROR classification. Rejecting Boolean IDs is deliberately stricter than go-ethereum, following section 4 (String / Number / Null). Section 4.2 requires structured params; java-tron classifies a non-null scalar as -32600 at the request-envelope layer, matching Besu's error-code classification, while geth classifies it as -32602 during method-argument parsing. For this malformed shape without an id, java-tron returns id: null, while geth sends no response. Full analysis and reproduction steps are in tronprotocol#6941.

Request-envelope validation intentionally precedes method lookup: an unknown method with scalar params changes from -32601 to -32600, while the same method with valid params: [] remains -32601. A malformed request without an id is not a valid notification. Successful notifications remain response-free; this change does not yet unify suppression of error responses across dispatch and servlet catches.

This PR has been tested by:

  • Unit Tests

  • Manual Testing

  • JsonRpcErrorResolverTest (16 tests) - mapped code / data priority, message defaults, sanitized unmapped exceptions, bounded logging, and fatal propagation including null/ordinary/deep/cyclic cause chains and TronError.

  • JsonRpcErrorSanitizationIntegrationTest (19 tests) - through a real JsonRpcServer and JsonRpcServlet: unmapped exceptions sanitized on the wire, four fatal categories escaping the server, servlet best-effort empty-500 handling, fixed messages for ExecutionException / InterruptedException, chain identity keeping -32001 with sanitized details, business messages preserved, batch isolation, transport contracts, scalar-params and request-ID rules, and real dispatch of supported params shapes.

  • JsonRpcDispatchContractTest (8 tests) - characterization of jsonrpc4j 1.6 dispatch (arity, null array elements, overload selection, scalar params) so a later framework upgrade cannot change behavior silently.

  • JsonRpcServletTest (64 tests) - request validation and pass-through; all three batch recovery points; exact-limit, replacement-overflow and missed/double-accounting guards; per-batch logging with mixed failures and reset across requests; fatal-after-failure stops dispatch without another log; preserved missing-ID behavior; direct and wrapped fatal propagation; response-wrapper cycles/depth limits and cleanup failures.

  • LogBlockQueryFailureTest (2 tests) - cause logging and interrupt-flag restoration.

  • TronJsonRpcImplChainIdentityTest (1 test) - one WARN per failure episode, recovery INFO, and a new WARN after recovery.

  • JsonRpcServletJettyTest (3 tests) - filtered and unfiltered fatal paths plus a healthy filtered request. For JSON, HTML and plain-text Accept values, the production-filter test requires server-side evidence of committed status 500 and zero content length; an empty 500 or a closed connection is then accepted. The observation filter never commits the response.

A package-private server setter supports servlet tests without changing production initialization. CachedBodyRequestWrapperTest is removed together with the class. On 2026-09-07, a cleanTest --no-build-cache JDK 17 (arm64) run passed 28 test classes / 312 tests (0 failures, 0 errors, 0 skipped); the seven focused classes above contain 113 tests. checkstyleMain, checkstyleTest, and git diff --check also pass. Java 8/x86 execution was not performed on this arm64 machine.

Compatibility

Breaking, limited to observable failure-handling paths; no request that succeeds today starts failing.

Case Before After
Unmapped exception -32001, exception message (may be null), data = class name -32603 "Internal error", no data
net_version / eth_chainId failure -32001, underlying message, data = class name -32001 "Chain identity unavailable", data "{}"
ExecutionException / InterruptedException -32000, cause toString() / null -32000 "Internal error"
Boolean / object / array request ID Single request: HTTP 200, empty body; batch: only -32603 / id: null, then processing stops -32600 "Invalid Request", id: null; batch siblings continue
Non-null scalar params After a registered method is selected: single request is HTTP 200 with an empty body; batch returns only -32603 / id: null and stops. An unknown method returns -32601 before checking params. -32600 "Invalid Request", no data; valid id preserved, otherwise id: null; batch siblings continue. Envelope validation precedes method lookup, so unknown + scalar changes to -32600, while unknown + params: [] stays -32601.
Fatal Error (VirtualMachineError / ThreadDeath / LinkageError / TronError) converted into -32001 / -32000 propagates after a best-effort empty HTTP 500; the connection may close instead, and a batch loses accumulated results
Single handleRequest throws non-fatal RuntimeException / IOException hidden by the old servlet-level entry point with a valid ID, HTTP 200 / -32603; without id, the existing empty body is retained
Recoverable batch request-serialization, dispatch or response-parsing failure Serialization failures, escaped dispatch RuntimeExceptions and response-parsing failures each return a single -32603 with id: null, discarding earlier results and skipping later elements. A dispatch IOException was not caught at all and propagated to outer handlers without this JSON-RPC response guarantee. preserve earlier results, append -32603 for the failed element and continue within the existing overflow rules; missing id still produces id: null

Unchanged: successful responses, HTTP 200 whenever a normal JSON-RPC response is produced, existing dispatch and method validation for missing / null / Array / Object params, data "{}" on the 62 existing mapped errors, deliberate business messages such as "filter not found", gRPC and non-JSON-RPC HTTP APIs. Fatal and genuine transport failures are not normal JSON-RPC responses and do not carry an HTTP-200 guarantee.

Before merge:

Follow up

  • Validation of the jsonrpc and method members, whether to reject params: null, and the final semantics of an explicit id: null on an otherwise valid request belong to [Feature]Standardize JSON-RPC error handling(revert codes, LiteNode pruned-history responses, request fields validation) tronprotocol/java-tron#6676, which overlaps this PR in JsonRpcServlet and the TronJsonRpc annotation blocks (see Extra details).
  • jsonrpc4j's precision loss when round-tripping large integer or high-precision numeric request IDs is a separate compatibility follow-up; servlet-generated errors in this PR preserve the original JsonNode ID.
  • Resolve notification suppression with the discussion on [Feature] Standardize JSON-RPC error mapping and exception boundaries tronprotocol/java-tron#6941: currently jsonrpc4j-generated errors are forwarded, a single servlet catch without id is silent, and a batch catch without id emits id: null. If suppression is adopted, it must cover forwarding and synthesized-error exits together and be documented as a behavior change.
  • Review the five mapped -32000 catch sites across three methods (eth_call, eth_estimateGas, buildTransaction) that still forward the underlying exception message; changing those public business-error messages needs its own compatibility review.
  • jsonrpc4j 1.6 -> 1.7 upgrade, fixing the parameter type mismatch that returns -32700 and loses the request id.
  • Container-wide sanitization of non-413 Jetty error pages remains a separate HTTP-layer hardening topic; this PR protects only Errors that escape JsonRpcServlet.doPost, on a best-effort basis.

Extra details

maxResponseSize continues to limit dispatched response accumulation. As on existing servlet-generated error paths, protocol error envelopes are still emitted and may make the final body exceed that threshold. Request-body and token limits, together with the batch-size limit when enabled, bound this behavior; redefining the threshold as a hard final-body cap is out of scope for this PR.

The per-batch log policy bounds only the servlet's escaped-dispatch failure log point, not business, resolver or container logging, and is not a cross-request rate limiter. Enabling DEBUG exposes repeated failure indices and types but not their Throwable or message. Single-request non-fatal dispatch failures are logged individually at ERROR with the Throwable.

The fatal cleanup bypasses response wrappers locally; it does not modify shared HTTP wrapper semantics. The missing flush delegation in CharResponseWrapper / ServletOutputStreamCopy can be discussed with tronprotocol#6936 independently. The embedded-Jetty regression installs the actual HttpInterceptor; an outer observer only records the underlying response's committed state, status and zero content length, and never commits it on behalf of the servlet.

An object with scalar params and no id is malformed rather than a valid notification, so it receives -32600 with id: null. Envelope validation also intentionally precedes method lookup; clients probing method availability should use a structurally valid params array or object.

This PR overlaps the request-envelope validation planned in tronprotocol#6676 in JsonRpcServlet and the eth_getLogs @JsonRpcErrors block of TronJsonRpc. There is no dependency between the two: this PR can land first and tronprotocol#6676 can build on the pre-dispatch checks added here; if tronprotocol#6676's PR lands first, this one will be rebased.

Pre-submit checklist:

  • Google Java Style; Checkstyle passes on main and test sources
  • No debug code, temporary comments or TODOs
  • No numeric computation or narrowing casts introduced
  • Logging: unmapped exceptions are WARNed once per method/type and repeated only at DEBUG; chain identity logs failure-state transitions; each batch logs its first escaped dispatch failure at ERROR with a stack and repeats only at DEBUG without a stack/message; async log-query failure/interruption retain their call-site WARNs; nothing logs on the normal request path
  • No DB, consensus, config or dependency changes
  • Comments explain why handleRequest is required and why the chain-identity cause must be retained

Closes tronprotocol#6941
Refs tronprotocol#6676

@waynercheung
waynercheung force-pushed the feat/jsonrpc-error-sanitization branch from 993c3dc to 65da80f Compare September 2, 2026 09:46
Replace jsonrpc4j's unhandled -32001 fallback with a fixed -32603
response so unmapped exception types and raw messages no longer reach
clients. Keep the documented -32001 contract for net_version and
eth_chainId through explicit mappings with fixed message and data.

Propagate VirtualMachineError, ThreadDeath, LinkageError, and TronError
through the JSON-RPC boundary. Scan complete cause chains without
allocating a visited set, detecting cycles without missing fatal causes.
Check exceptions escaping dispatch before logging or mapping them.

Before rethrowing an Error, unwrap response decorators with a bounded
walk and make a best-effort attempt to commit an empty HTTP 500 on the
underlying response. Preserve the original error if cleanup fails, and
abandon cleanup without delegating into cyclic or unresolved wrappers.

Route single requests through handleRequest because the servlet handle
API catches Throwable. Convert escaped RuntimeException and IOException
instances into -32603 responses when a response is appropriate.

Recover each failed batch element without discarding earlier results or
skipping later requests. Share internal-error construction across
serialization, dispatch, and response-parsing failures. Count only the
replacement when parsing fails, retaining existing overflow rules and
the current single/batch handling of requests without an id.

Bound unmapped-exception logging by RPC method and exception type, and
log chain-identity failures on state transitions. Log the first escaped
dispatch failure in each batch at ERROR with its cause; repeats use
DEBUG with only the index and exception type. Restore interrupted
status for asynchronous log queries.

Reject Boolean, object, and array request IDs before dispatch with
-32600 and id:null. Reject non-null scalar params with -32600, preserve
valid IDs, and isolate invalid batch elements so their siblings run.
Missing, null, array, and object params keep their existing semantics.

Remove the obsolete request replay wrapper and HTTP status provider.
Add resolver, servlet, embedded-Jetty, chain-identity, asynchronous
failure, dispatch-contract, and request-envelope regression coverage.
Exercise the real HTTP interceptor chain and observe commitment without
performing it on the servlet's behalf.

Add a package-private server injection seam for servlet tests without
changing production initialization.
@waynercheung
waynercheung force-pushed the feat/jsonrpc-error-sanitization branch from 65da80f to fb704f5 Compare September 7, 2026 11:51
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.

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

1 participant