跳到正文
DEV Community· BlockVectra·· 3 小時前AI 評分48

JSON-RPC 批次回應:按 id 配對,而非按陣列位置

JSON-RPC batch responses: match by id, not array position

AI 導讀

JSON-RPC 批次回應未必按請求陣列順序排列,客戶端應以請求和回應共用的 id 配對,而非依賴陣列位置。無依賴的 JSON-RPC 2.0 correlateBatch 實作會檢查缺失、重複及未知的回應 id,並要求每個回應只帶 result 或 error;HTTP 200 仍可能包含單項錯誤。

正文

By BlockVectra, with AI-assisted drafting.

A JSON-RPC batch puts several calls in one request. The reply array is not a positional mirror of the request array. Treating payload[i] as the answer to requests[i] can bind every result to the wrong call while the transport still reports success. Here is a three-response mismatch:

const requests = [
  { jsonrpc: "2.0", id: 7, method: "eth_blockNumber", params: [] },
  { jsonrpc: "2.0", id: 8, method: "eth_getBalance", params: ["0x0000000000000000000000000000000000000000", "latest"] },
  { jsonrpc: "2.0", id: 9, method: "eth_chainId", params: [] },
];

const payload = [
  { jsonrpc: "2.0", id: 9, result: "0x1" },
  { jsonrpc: "2.0", id: 7, error: { code: -32000, message: "busy" } },
  { jsonrpc: "2.0", id: 8, result: "0x0" },
];

requests.forEach((request, index) => {
  const response = payload[index];
  console.log(request.id, response.id);
});
// 7 9
// 8 7
// 9 8

Every request is paired with another request's id. Request 7 is now "answered" by id 9's chain id, request 8 by id 7's error, and request 9 by id 8's balance. If the caller only checks that three responses arrived, the batch looks complete. A downstream parser may then apply an eth_chainId result where an eth_blockNumber result was expected. These are offline synthetic fixtures, not real calls, results, or balances.

The specification permits this: response objects "MAY be returned in any order within the Array." The stable join key is the id carried by both the request and its response. A response's position carries no contract.

HTTP 200 is not batch success

A batch can return HTTP 200 while containing an item-level error. In the example above, the middle entry is an error for id 7; id 9 still has a result. A status-code check sees only transport success. Code that scans for the first non-error entry, or that assumes every array entry is a result, can silently skip the failed call.

The same rule applies to completeness. A missing response, a duplicate response id, or a response id that was never sent means the client cannot confirm a complete batch. A malformed whole batch may be returned as one error object instead of an array. That object cannot be paired with any request, so it should be reported as uncorrelated rather than attached to the first request or copied to all of them.

Each response must carry exactly one of result or error, and an error must be a non-null object with an integer code and a string message. A result is allowed to be false, 0, or null; checking truthiness is not a valid presence test.

A strict correlator

The complete implementation follows. It is dependency-free and can be copied as one module.

const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);

const isPositiveSafeInt = (value) => Number.isSafeInteger(value) && value > 0;

function fail(message) {
  throw new Error(`correlateBatch: ${message}`);
}

function assertErrorShape(error, id) {
  if (error === null || typeof error !== "object" || Array.isArray(error)) {
    fail(`request ${id} returned a non-object error`);
  }
  if (!Number.isInteger(error.code) || typeof error.message !== "string") {
    fail(`request ${id} returned a malformed error`);
  }
}

/**
 * Correlate a JSON-RPC 2.0 batch response array with its requests by id.
 * This checks envelope, id, and error shape only, not result semantics.
 */
export function correlateBatch(requests, payload) {
  if (!Array.isArray(requests) || requests.length === 0) {
    fail("requests must be a non-empty array");
  }
  const expected = new Set();
  for (const request of requests) {
    if (request === null || typeof request !== "object" || !hasOwn(request, "id")) {
      fail("every request must be an object with its own id");
    }
    if (!isPositiveSafeInt(request.id) || expected.has(request.id)) {
      fail("request ids must be unique positive safe integers");
    }
    expected.add(request.id);
  }
  if (!Array.isArray(payload) || payload.length === 0) {
    fail("uncorrelated: response payload must be a non-empty array");
  }
  const responses = new Map();
  for (const row of payload) {
    if (row === null || typeof row !== "object" || Array.isArray(row)) {
      fail("every response row must be a non-null object");
    }
    if (row.jsonrpc !== "2.0") {
      fail("every response row must declare jsonrpc 2.0");
    }
    if (!hasOwn(row, "id")) {
      fail("every response row must carry its own id");
    }
    if (!expected.has(row.id)) {
      fail(`unknown response id ${String(row.id)}`);
    }
    if (responses.has(row.id)) {
      fail(`duplicate response id ${row.id}`);
    }
    const hasResult = hasOwn(row, "result");
    const hasError = hasOwn(row, "error");
    if (hasResult === hasError) {
      fail(`request ${row.id} must carry exactly one of result or error`);
    }
    if (hasError) {
      assertErrorShape(row.error, row.id);
    }
    responses.set(row.id, row);
  }
  for (const id of expected) {
    if (!responses.has(id)) {
      fail(`missing response id ${id}`);
    }
  }
  return responses;
}

It first validates the expected ids. It then rejects non-object rows, a wrong jsonrpc version, a missing row id, an unknown id, a duplicate id, and a result/error pair that is not exactly one present. Finally, it checks that every expected id was seen. The returned Map keeps the original response object for each id, so a result or error is not rewritten.

Consume successes, fail on errors

The snippet is application pseudocode. validateForMethod and consume are application-provided; only the correlator above is a complete, copyable module.

import { correlateBatch } from "./batch-response.mjs";

const byId = correlateBatch(requests, payload);

const values = requests.map((request) => {
  const response = byId.get(request.id);
  if (Object.hasOwn(response, "error")) {
    throw new Error(`RPC ${response.error.code}: ${response.error.message}`);
  }
  return validateForMethod(request.method, response.result);
});

for (const value of values) {
  consume(value);
}

Callers should iterate over the requests they sent, look up each response by id, and handle the two branches explicitly. Error entries remain errors. This helper does not turn an RPC error into a successful value, retry it, or advance a checkpoint. The map step validates the whole batch before any consume call, so later errors block earlier consumptions. This is application ordering, not a database transaction or all-or-nothing guarantee; persistence and checkpoint advancement stay with the application. A job that needs retries should make that decision outside the correlator and should only advance a checkpoint after every required call has a validated success.

Do not skip the method-specific step. A syntactically valid result can still be the wrong shape for eth_getLogs, eth_getBalance, or any other method. Envelope validation is not result validation.

Scope and limits

This example is deliberately stricter than the full protocol. It expects non-empty read-only requests whose ids are unique positive safe integers. It does not cover notifications, null ids, string ids, or fractional ids. Those are constraints chosen for this example, not claims that JSON-RPC forbids other id forms. The function checks envelope, id, and error shape only. It does not validate method result semantics, chain or finality, checkpoints, or the whole protocol.

Batch size and concurrency are different budgets. Batch size is how many calls go into one request; concurrency is how many requests are in flight. Lowering one does not automatically lower the other. Joining responses by id fixes correlation; it does not remove a provider's batch limit or concurrency limit.

If you are querying logs over a block range, the BlockVectra getLogs guide is a natural next step: Get logs over a block range.

By BlockVectra, with AI-assisted drafting. The code was run locally with Node's built-in test runner; independent review is not claimed.

Runnable MIT companion and 14 offline checks: code and tests. Download both .mjs files and run node --test batch-response.test.mjs.

來源:DEV Community · dev.to