# JSON-RPCの一括応答を配列順で読まない：id・部分失敗・欠損の検証

JSON-RPCのバッチ応答を送信順に対応付けるバグを、逆順の合成応答で再現。idの型、重複、未知id、部分失敗、null、通知を分けて検証します。

正規URL: https://3mikan.com/archives/6077

著者: みかん
公開: 2026-09-06T00:00:00.000Z
更新: 2026-09-06T00:00:00.000Z

複数のRPCをまとめたら、残高の欄に別の値が入った。どの値も16進数としては正しいので、形式チェックにも引っかからない。こうしたバグでは、**応答配列の順番と、送信した要求の順番を同じだと思っていないか**を確認します。

この記事は、Ethereum RPCなどの一括処理を実装する開発者向けです。外部RPCへは接続せず、ローカルのJSONから要求と応答を正しく照合する方法を確かめます。速度比較やプロバイダー選びではなく、応答を取り違えないことが目的です。

掲載例はNode.js 22以降で実行できます。[rpc-batch.mjs](/examples/offline-protocol-reading/rpc-batch.mjs)を同じフォルダーへ保存し、各節のJavaScriptを別の`example.mjs`へ写して`node example.mjs`を実行してください。

## バッチ応答の順番は保証されない

JSON-RPC 2.0では、バッチに対する応答の順番は任意です。要求との対応は配列の位置ではなく、それぞれの`id`で確認します。[^source1]

次の二つの要求を送ったとします。

```json
[
  {"jsonrpc":"2.0","id":"chain","method":"eth_chainId","params":[]},
  {"jsonrpc":"2.0","id":"height","method":"eth_blockNumber","params":[]}
]
```

説明用の応答は、意図的に逆順へ並べています。下のブロック番号は架空の値で、現在のチェーンの観測ではありません。

```json
[
  {"jsonrpc":"2.0","id":"height","result":"0x41"},
  {"jsonrpc":"2.0","id":"chain","result":"0x1"}
]
```

これを`responses[0].result`からチェーンIDへ代入すると、架空のブロック番号65をチェーンIDとして扱ってしまいます。両方とも数量として正しい16進表記なので、値の形式だけを確認しても取り違えは検出できません。

## 受信後にidで対応付け、最後に必要な順へ並べる

付属の`rpc-batch.mjs`は、要求から期待するIDの集合を作り、各応答を照合した後、要求順に結果を返します。

```js
import { matchBatch } from './rpc-batch.mjs';

const requests = [
  { jsonrpc: '2.0', id: 'chain', method: 'eth_chainId', params: [] },
  { jsonrpc: '2.0', id: 'height', method: 'eth_blockNumber', params: [] },
];
const responses = [
  { jsonrpc: '2.0', id: 'height', result: '0x41' },
  { jsonrpc: '2.0', id: 'chain', result: '0x1' },
];
console.log(matchBatch(requests, responses));
```

```text
[
  { id: 'chain', ok: true, result: '0x1' },
  { id: 'height', ok: true, result: '0x41' }
]
```

内部で大切なのは、Mapに入れる前に「送ったIDか」「同じIDが既に返っていないか」を確認することです。単に`new Map(responses.map(...))`とすると、重複したキーを後の値で上書きしてしまいます。

```js
const expected = new Set(['chain', 'height']);
const found = new Map();

for (const response of responses) {
  if (!expected.has(response.id)) throw new Error('unknown response id');
  if (found.has(response.id)) throw new Error('duplicate response id');
  found.set(response.id, response);
}
if (found.size !== expected.size) throw new Error('missing response id');
```

直前の例で定義した`responses`へ続ける、対応確認部分だけを抜き出した例です。完全なサンプルでは、その前に要求・応答の形式とIDの型を確認し、後で成功・エラーを分けています。

## 数値の7と文字列の"7"を一緒にしない

JSON-RPCのIDは文字列または数値などを取れますが、応答は要求のIDと同じ値を返します。受信側で勝手に文字列へ変換してから照合する設計は避けます。[^source1]

```js
const byId = new Map([[7, 'numeric'], ['7', 'string']]);
console.log(byId.size); // 2
console.log(byId.get(7));   // numeric
console.log(byId.get('7')); // string
```

このサンプルの入力方針は、文字列か、非負の安全な整数だけを許可するものです。`null`、小数、負数、`-0`、安全な整数の範囲外の値は使いません。これは取り違えを減らすための実装上の制約であり、JSON-RPCがそのすべてを一律に禁止しているという意味ではありません。

特に巨大なIDをJSONの数値として渡すと、JavaScriptで読み込む段階の精度が問題になります。長い通し番号や外部で付けた識別子には、最初から文字列を使う方針が扱いやすくなります。

## 一件の失敗と、応答全体の不正を分ける

JSON-RPC応答には`result`か`error`のどちらかが入り、両方は入りません。バッチの中には、成功した応答と個別のエラー応答が混在できます。[^source1]

ここでは個別エラーを`ok: false`の結果として残します。一方、未知IDや重複、欠損は、対応付け自体を信頼できないため、例外として止めます。

| 状態 | サンプルでの扱い | 理由 |
| --- | --- | --- |
| 逆順だがIDは対応している | 要求順へ並べ直す | 並び順は意味ではない |
| 既知IDの`error` | 個別の失敗として残す | 別の要求の成功を消さない |
| 同じIDが2回ある | 全体の照合を停止 | どちらを採用するか決めない |
| 送っていないIDがある | 全体の照合を停止 | 別の要求へ割り当てない |
| 期待したIDがない | 全体の照合を停止 | 欠損をゼロで補わない |
| `result`と`error`が両方ある | 形式不正として停止 | 成功か失敗かを推測しない |

エラーの分類によって、何を保存するかが変わります。`-32601`のような個別エラーコードと、JSONとして読めなかった問題は、同じ「RPC失敗」の一言にまとめないようにします。

## result:nullを「結果なし」と判定しない

`if (response.result)`のような真偽値による確認では、`null`、`false`、0、空文字列を、プロパティそのものがない状態と混同します。

```js
const a = { jsonrpc: '2.0', id: 'receipt', result: null };
const b = { jsonrpc: '2.0', id: 'receipt' };

console.log(Object.hasOwn(a, 'result')); // true
console.log(Object.hasOwn(b, 'result')); // false
```

ここでの`ok: true`は、JSON-RPCの成功側の形式で返った、という意味に限定します。たとえば`eth_getTransactionReceipt`の`null`はレシートが見つからない状態であり、トランザクションの成功を表すわけではありません。[^source2]

対応付けが成功した後に、メソッドごとの値の意味を別途確認します。

## 通知と、バッチ全体のエラーは別の経路にする

`id`を省略した要求は通知で、応答を返す対象ではありません。通知だけのバッチにも、空配列の応答を期待するべきではありません。[^source1]

サンプルでは通常の要求に混ざった通知は応答数から外します。一方、通知だけの送信は`matchBatch`へ渡さず、応答を照合しない処理へ分けます。今回は受信結果を扱う読み取り例に限定し、送信や再送の実装は扱いません。

また、不正JSONや空の要求配列など、バッチ自体が認識されない場合には、単一のエラーオブジェクトが返る場合があります。これを無理に一要素配列へ包んで、最初の要求の失敗と決めつけないようにします。[^source1]

## IDが一致しても、同じ時点のデータとは限らない

この検証で解決するのは「どの要求への返答か」です。全件を同じブロックで読んだことや、一つの原子的な操作として実行されたことまで保証するものではありません。

時点をそろえる課題は、[Ethereum RPCの固定ブロック・履歴状態・Multicallの解説](/archives/6024)へ分けます。本記事のID照合を、スナップショットの保証として使わないことが重要です。

## まとめ

一括応答は、配列順ではなくIDで照合します。その際、型、未知ID、重複、欠損を検査し、個別エラーと応答全体の不正を分けます。

付属コードが扱うのは、JSONとして読み込んだ後の要求と応答です。HTTPの失敗、タイムアウト、応答サイズ、重複したJSONプロパティ名、メソッドごとの結果型は別途検証が必要です。この境界を明確にしておくと、形式が正しい値を別の用途へ割り当てるバグを見つけやすくなります。

## 参考資料

確認日：2026年9月5日。Node.js v22.16.0で、外部通信なしの合成要求・応答を使って検証しました。

[^source1]: [JSON-RPC 2.0 Specification: Request / Response / Batch](https://www.jsonrpc.org/specification)
[^source2]: [Ethereum JSON-RPC API: eth_getTransactionReceipt](https://ethereum.org/en/developers/docs/apis/json-rpc/)

## 確認した一次情報

- [JSON-RPC 2.0 Specification: Request / Response / Batch](<https://www.jsonrpc.org/specification>): 確認日 2026-09-05
- [Ethereum JSON-RPC API: eth\_getTransactionReceipt](<https://ethereum.org/en/developers/docs/apis/json-rpc/>): 確認日 2026-09-05
