Logo
New RPC users get 35% off their first monthView the offer
OnFinality Learn
Troubleshooting12 min read

JSON-RPC Error Object: Decode code, message, data

Learn the exact JSON-RPC 2.0 error object contract and how to classify any RPC failure by code, message, and data.

TL;DR

The JSON-RPC 2.0 error object has exactly three members: code (integer, required), message (string, required), and data (optional, unconstrained). The specification pre-defines five codes from -32700 to -32603 and reserves -32000 to -32768 for implementation-defined server errors. A correct client distinguishes an error response from a successful null result and from a transport failure, classifies codes into retry, fix, or abort actions, and never parses the human-readable message. This article shows the contract, a runnable Node.js decoder, batch id mapping, and a results table you complete against your own endpoint.

The JSON-RPC 2.0 error object contract

The JSON-RPC 2.0 specification defines the error object in Section 5.1 with three members: code, message, and data. Only code and message are required; data is optional and the specification deliberately leaves its type and meaning unconstrained. A response object must contain either result or error, never both, and the id member must match the request.

Because result and error are mutually exclusive, the presence of the error key is the normative signal of failure. The code member is an integer and is the only stable discriminator a client can branch on. The message member is a short human-readable string, and the specification explicitly notes that it is intended for developers rather than end users.

This contract is transport-agnostic. Whether you call an Ethereum node through OnFinality's Ethereum network endpoint or any other JSON-RPC service, the same three-member shape applies. Provider-specific behavior beyond the pre-defined codes is documented per provider and varies by provider.

  • code: integer, required, the stable machine-readable discriminator.
  • message: string, required, human-readable and non-normative for logic.
  • data: optional, unconstrained, the escape hatch for structured detail.
  • result and error are mutually exclusive in a single response object.

Pre-defined codes and the reserved server range

Section 5.1.1 of the JSON-RPC 2.0 specification pre-defines five error codes. -32700 is Parse error, meaning invalid JSON was received. -32600 is Invalid Request, meaning the JSON was valid but not a valid Request object. -32601 is Method not found. -32602 is Invalid params. -32603 is Internal error.

The specification also reserves the range -32000 to -32768 for implementation-defined server errors. This is the critical caveat: codes inside that range have no cross-provider meaning. A node may reuse -32000 for many distinct conditions, and providers may add their own codes outside the pre-defined set. Cross-provider code comparisons are therefore only reliable for the five pre-defined codes.

The Ethereum JSON-RPC specification builds on this base and documents Ethereum-specific error behavior, including revert data placement. Treat the Ethereum spec as authoritative for Ethereum semantics and the JSON-RPC 2.0 spec as authoritative for the envelope.

  • -32700 Parse error: invalid JSON.
  • -32600 Invalid Request: valid JSON, invalid Request object.
  • -32601 Method not found.
  • -32602 Invalid params.
  • -32603 Internal error.
  • -32000 to -32768: reserved for implementation-defined server errors.

Distinguishing error responses from null results and transport failures

The single most common client bug is treating result: null and error: {...} as the same failure. They are not. A successful call may legitimately return null — for example, a block lookup for a height that does not exist yet — and that is a success, not an error. A transport failure is a different layer entirely: the HTTP request may fail, time out, or return a non-JSON body before any JSON-RPC envelope exists.

A correct type guard checks three things in order: did the transport succeed, did the body parse as JSON, and does the parsed object contain an error member. Only the third condition is a JSON-RPC error. This ordering prevents you from misclassifying a network outage as a protocol error or a null result as a failure.

The same discipline applies when you read revert reasons and custom errors: the revert detail lives inside the error object's data, but only after you have confirmed you are looking at an error object at all.

function classifyResponse(httpOk, bodyText) {
  if (!httpOk) return { kind: 'transport', retryable: true };
  let parsed;
  try {
    parsed = JSON.parse(bodyText);
  } catch (e) {
    return { kind: 'transport', retryable: true, reason: 'non-json body' };
  }
  if (parsed && typeof parsed === 'object' && 'error' in parsed) {
    return { kind: 'jsonrpc-error', error: parsed.error, id: parsed.id };
  }
  if (parsed && typeof parsed === 'object' && 'result' in parsed) {
    return { kind: 'success', result: parsed.result, id: parsed.id };
  }
  return { kind: 'malformed', retryable: false };
}

Mapping code ranges to retry, fix, or abort actions

Once you have confirmed an error object, the code drives the action. Parse errors (-32700) are client bugs: your serializer produced invalid JSON, and retrying the identical payload will fail identically. Invalid Request (-32600) is also a client bug in the request envelope. Neither is retryable.

-32601 Method not found and -32602 Invalid params are almost always a wrong method name or a malformed params array. These are fixed by a code change, not by retrying. -32603 Internal error is the ambiguous one: it may be transient, so retry once and then surface it. Codes in the -32000 range are node- or chain-specific and must be handled per chain, because the same numeric code can mean different things on different providers.

This classification is the generic layer that sits beneath chain-specific decoders such as Solana simulateTransaction error decoding. The generic layer decides whether to retry; the chain-specific layer decides what the failure means.

  • -32700, -32600: client bug, never retry, fix the payload.
  • -32601, -32602: wrong method or params, fix in code, do not retry.
  • -32603: retry once, then surface to the caller.
  • -32000 range: chain- or node-specific, handle per chain.
  • Unknown codes: surface with full context rather than guessing.

Why message must never be parsed and code is the only stable key

The message member is human text. Providers localize it, wrap it, or rewrite it, and the specification does not constrain its wording. Any client that string-matches on message is coupled to a provider's phrasing and will break when that phrasing changes. The code member is the only stable discriminator.

The caveat is that providers reuse -32000 for many distinct conditions and may add their own codes outside the specification. That means code is stable within a provider's documented set but not necessarily comparable across providers. Log the message for humans, branch on the code for logic, and keep a per-provider mapping table for the -32000 range.

If you need to compare behavior across providers, restrict your automated logic to the five pre-defined codes and treat everything else as provider-specific. This is the same separation of concerns you apply when reasoning about JSON-RPC idempotency and duplicate-request safety: the protocol guarantees the envelope, not the vendor's semantics.

The data field as the escape hatch for structured detail

The data member is where implementations put structured detail that does not fit in code or message. For Ethereum, a revert string or an ABI-encoded custom error is commonly placed there. The specification does not require data to be present, so a client must never assume it exists.

The practical consequence is that your decoder should treat data as optional and validate its shape before use. If you expect an ABI-encoded custom error, check that data is a hex string of sufficient length before attempting to decode it. If it is absent, fall back to the code and message for classification.

When you simulate calls with eth_call state overrides, the same data field carries the revert detail, so the decoding path is shared between live calls and simulations.

function extractRevertData(error) {
  if (!error || typeof error !== 'object') return null;
  const d = error.data;
  if (typeof d === 'string' && /^0x[0-9a-fA-F]*$/.test(d)) return d;
  if (d && typeof d === 'object' && typeof d.data === 'string') return d.data;
  return null;
}

Runnable Node.js example: raw fetch versus wrapped library error

Libraries hide the raw error object. The example below issues a raw fetch call so you can see the exact JSON-RPC error envelope, then shows how a wrapped library error typically nests the same fields. Run it against your own endpoint to observe the real shape your provider returns.

Replace the endpoint URL with your own. The goal is not to produce a specific result but to reveal the difference between the transport response and the library's abstraction, so you can decide which layer your error handling should live in.

const endpoint = process.env.RPC_URL || 'https://your-endpoint.example';

async function rawCall() {
  const res = await fetch(endpoint, {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      jsonrpc: '2.0',
      id: 1,
      method: 'eth_getBalance',
      params: ['0x0000000000000000000000000000000000000000', 'latest']
    })
  });
  const text = await res.text();
  console.log('http status:', res.status);
  console.log('raw body:', text);
  try {
    const parsed = JSON.parse(text);
    if (parsed.error) {
      console.log('code:', parsed.error.code);
      console.log('message:', parsed.error.message);
      console.log('data present:', 'data' in parsed.error);
    }
  } catch (e) {
    console.log('body was not JSON');
  }
}

rawCall().catch((e) => console.error('transport failure:', e.message));

Batch responses and mapping errors back to requests by id

A batch request sends an array of request objects and receives an array of response objects. Some entries may carry result and others may carry error, and each entry carries its own id. The array order is not guaranteed to match the request order, so you must map by id rather than by position.

Build a map from id to request before sending, then iterate the response array and attach each result or error to its originating request. This is the only reliable way to know which call failed when a batch partially succeeds.

If you are batching writes, review JSON-RPC idempotency and duplicate-request safety before retrying failed entries, because a retry of a partially applied batch can duplicate effects.

function mapBatch(requests, responses) {
  const byId = new Map(requests.map((r) => [r.id, r]));
  return responses.map((resp) => {
    const req = byId.get(resp.id);
    if (resp.error) {
      return { id: resp.id, method: req && req.method, status: 'error', error: resp.error };
    }
    return { id: resp.id, method: req && req.method, status: 'ok', result: resp.result };
  });
}

Results table: measuring error behavior against your own endpoint

The table below is a reproducible measurement template. Fill it in by sending each request to your own endpoint and recording what comes back. Do not rely on numbers from this article; the point is to observe your provider's actual behavior.

Run each request at least twice to distinguish deterministic errors from transient ones. Record whether data is present, because that determines whether you can decode structured detail. Then assign a classification and an action using the ranges above.

  • Request: the exact JSON-RPC method and params you sent.
  • Code: the integer from error.code.
  • Message: the string from error.message, recorded verbatim.
  • Data present?: yes or no, and its type if present.
  • Classification: parse, invalid request, method, params, internal, or server-range.
  • Action: retry, fix, or abort, with the reason.

Limitations and tradeoffs of generic error decoding

The specification deliberately leaves the -32000 range to implementations, so cross-provider code comparisons are only reliable for the pre-defined set. Any logic that assumes a specific -32000 meaning will be provider-coupled and may break when you switch endpoints or when a provider changes its mapping.

Structured data must never be assumed present. A decoder that requires data will fail on providers that omit it. The safe pattern is to attempt structured decoding when data exists and fall back to code plus message when it does not.

There is also a tradeoff between generic and chain-specific handling. Generic classification decides retry versus fix; chain-specific decoders decide meaning. Keeping these layers separate makes both easier to test, but it means you maintain two mappings instead of one.

Troubleshooting checklist for recurring RPC failures

When failures recur, work through the layers in order. First confirm the transport succeeded and the body parsed as JSON. Then confirm you are looking at an error member rather than a null result. Then read the code and classify it. Only after that should you inspect data.

If the code is -32601 or -32602, check the method name and params array against the Ethereum JSON-RPC specification before changing anything else. If the code is in the -32000 range, consult your provider's documentation, because the meaning is provider-specific.

For endpoint selection and connectivity issues that are not protocol errors, the RPC endpoints guide covers how to choose and verify an endpoint. For capacity planning around retries, see RPC pricing and the API service overview.

  • Transport ok? Body parsed as JSON?
  • Is there an error member, or is this a null result?
  • What is the code, and which range does it fall in?
  • Is data present, and does its shape match your expectation?
  • Is the code provider-specific, requiring per-provider handling?

Next steps: building a reusable error decoder

Turn the pieces above into a small module: a type guard that separates transport, success, and error; a classifier that maps codes to retry, fix, or abort; and an optional data extractor for chain-specific decoding. Keep the provider-specific -32000 mapping in configuration rather than in code so you can update it without a release.

Then wire the module into your call sites and log the full error object, including code, message, and whether data was present. That log is what makes the results table reproducible over time.

For broader context on RPC usage patterns, start from the OnFinality Learn hub and the Ethereum network page. The goal is a decoder that survives a provider change because it branches on the protocol contract, not on a vendor's wording.

Never Worry about Infrastructure Again

OnFinality takes away the heavy lifting of DevOps so you can build smarter and faster.

Get Started