Featured image of post JSON-RPC 2.0エラーオブジェクトにおけるID必須仕様 - 8年越しの誤解と正しい理解

JSON-RPC 2.0エラーオブジェクトにおけるID必須仕様 - 8年越しの誤解と正しい理解

JSON-RPC 2.0のエラーレスポンスにおけるID必須仕様について、仕様の意図、実装の注意点、他のRPCプロトコルとの比較を通じて詳しく解説します。JSON::RPC::Specの8年ぶりの修正から学んだこと。

はじめに - 8年越しの誤解

恥ずかしながら、私は8年もの間、JSON-RPC 2.0の仕様を誤解していました。

具体的には、「エラーレスポンスの場合は、リクエストIDが正しく取得できた場合でも、IDをnullにして返す必要がある」と解釈していたのです。この誤解のまま、自作のPerlモジュール JSON::RPC::Spec を実装し、CPANに公開していました。

最近、別の用途でJSON-RPC 2.0の仕様を見直す機会があり、ようやく自分の勘違いに気づきました。正しくは「IDは可能な限り保持する」というスタンスの仕様だったのです。

この記事では、JSON-RPC 2.0におけるエラー時のID必須仕様について、仕様の意図、実装の注意点、他のRPCプロトコルとの比較などを詳しく解説します。

JSON-RPC 2.0の基本構造

まず、JSON-RPC 2.0の基本的な構造を確認しておきましょう。

リクエストオブジェクト

JSON-RPC 2.0のリクエストは、以下の構造を持ちます。

1
2
3
4
5
6
{
  "jsonrpc": "2.0",
  "method": "subtract",
  "params": [42, 23],
  "id": 1
}
  • jsonrpc: プロトコルバージョン(必須、“2.0"固定)
  • method: 呼び出すメソッド名(必須)
  • params: パラメータ(省略可)
  • id: リクエスト識別子(通知以外では必須)

レスポンスオブジェクト

成功時のレスポンスは以下のようになります。

1
2
3
4
5
{
  "jsonrpc": "2.0",
  "result": 19,
  "id": 1
}

エラー時のレスポンスはこうなります。

1
2
3
4
5
6
7
8
{
  "jsonrpc": "2.0",
  "error": {
    "code": -32601,
    "message": "Method not found"
  },
  "id": 1
}

重要なのは、エラーレスポンスにもidフィールドが必須という点です。

エラーレスポンスにおけるID必須仕様

仕様の正確な記述

JSON-RPC 2.0の公式仕様書には、エラーレスポンスのIDについて、以下のように明記されています。

“id: It MUST be the same as the value of the id member in the Request Object. If there was an error in detecting the id in the Request object (e.g. Parse error/Invalid Request), it MUST be Null.”

日本語に訳すと:

「id: リクエストオブジェクトのidメンバーの値と同じでなければならない(MUST)。リクエストオブジェクトのidを検出する際にエラーが発生した場合(例:パースエラーや無効なリクエスト)、idはNullでなければならない(MUST)。」

私の誤解と正しい理解

私が誤解していたのは、この「MUST be Null」の適用範囲でした。

誤解していた解釈:

  • エラーが発生した場合は、常にidをnullにする

正しい解釈:

  • リクエストからidを正しく取得できた場合は、そのidをそのまま返す
  • リクエストのパースに失敗するなど、idを取得できなかった場合のみ、nullを返す

つまり、IDは可能な限り保持するというのが仕様の意図なのです。

nullになるケース

IDがnullになるのは、以下のような限定的なケースのみです。

1. Parse Error(-32700)

JSONのパースに失敗した場合、リクエスト全体が読み取れないため、idも取得できません。

1
2
3
4
5
6
7
8
{
  "jsonrpc": "2.0",
  "error": {
    "code": -32700,
    "message": "Parse error"
  },
  "id": null
}

2. Invalid Request(-32600)

JSON自体は正しくても、JSON-RPCのリクエスト形式として不正な場合です。例えば、methodフィールドが欠けている場合など。

1
2
3
4
5
6
// 不正なリクエスト例
{
  "jsonrpc": "2.0",
  "params": [1, 2]
  // methodが欠けている!
}

この場合も、idを安全に取得できないと判断されれば、nullが返されます。

1
2
3
4
5
6
7
8
{
  "jsonrpc": "2.0",
  "error": {
    "code": -32600,
    "message": "Invalid Request"
  },
  "id": null
}

3. その他、idが取得不能な場合

リクエストにidフィールドが存在しない、または型が不正な場合なども該当します。

IDを保持するケース

一方、以下のようなエラーの場合は、リクエストのIDをそのまま返します。

Method not found(-32601)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
// リクエスト
{
  "jsonrpc": "2.0",
  "method": "nonexistent",
  "id": 42
}

// レスポンス
{
  "jsonrpc": "2.0",
  "error": {
    "code": -32601,
    "message": "Method not found"
  },
  "id": 42  // リクエストのIDを保持
}

Invalid params(-32602)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// リクエスト
{
  "jsonrpc": "2.0",
  "method": "subtract",
  "params": ["not", "numbers"],
  "id": "abc"
}

// レスポンス
{
  "jsonrpc": "2.0",
  "error": {
    "code": -32602,
    "message": "Invalid params"
  },
  "id": "abc"  // リクエストのIDを保持
}

Internal error(-32603)やアプリケーションエラー

これらも同様に、リクエストのIDを保持します。

なぜIDを可能な限り保持するのか

この「IDを可能な限り保持する」という設計には、深い意図があります。

非同期通信とリクエストの多重化

JSON-RPC 2.0は、WebSocketやHTTP/2のような非同期通信プロトコル上での利用を想定しています。

クライアントが複数のリクエストを並列に送信した場合、レスポンスの順序は保証されません。IDがあることで、どのレスポンスがどのリクエストに対応するのかを確実に識別できます。

1
2
3
4
5
6
7
// クライアント側の疑似コード
const promise1 = rpc.call("method1", [1], id: 1);
const promise2 = rpc.call("method2", [2], id: 2);
const promise3 = rpc.call("method3", [3], id: 3);

// レスポンスは順不同で返ってくる可能性がある
// IDがあることで正しく対応付けられる

もしエラーレスポンスのIDが常にnullだったら、どのリクエストが失敗したのか判別できなくなってしまいます。

トレーサビリティとデバッグ

ログやモニタリングシステムでリクエスト-レスポンスのペアを追跡する際、IDは重要な役割を果たします。

1
2
[INFO] Request received: id=12345, method=getUserInfo
[ERROR] Method not found: id=12345

IDが保持されていれば、ログを時系列で並べなくても、対応するリクエストとレスポンスを簡単に見つけられます。

バッチリクエストでの識別

JSON-RPC 2.0はバッチリクエスト(複数のリクエストを配列で一度に送信)をサポートしています。

1
2
3
4
5
[
  {"jsonrpc": "2.0", "method": "sum", "params": [1,2,4], "id": "1"},
  {"jsonrpc": "2.0", "method": "subtract", "params": [42,23], "id": "2"},
  {"jsonrpc": "2.0", "method": "get_data", "id": "3"}
]

レスポンスもバッチで返ってきますが、IDがあることで、どのレスポンスがどのリクエストに対応するかが明確になります。

1
2
3
4
5
[
  {"jsonrpc": "2.0", "result": 7, "id": "1"},
  {"jsonrpc": "2.0", "result": 19, "id": "2"},
  {"jsonrpc": "2.0", "error": {"code": -32601, "message": "Method not found"}, "id": "3"}
]

JSON::RPC::Specの修正内容

私が作成した JSON::RPC::Spec では、エラー時に常にIDをnullにする実装になっていました。

修正前のコード(概念)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# 誤った実装(概念)
sub create_error_response {
    my ($error_code, $error_message) = @_;
    return {
        jsonrpc => "2.0",
        error => {
            code => $error_code,
            message => $error_message,
        },
        id => undef,  # 常にnull(誤り)
    };
}

修正後のコード(概念)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
# 正しい実装(概念)
sub create_error_response {
    my ($error_code, $error_message, $request_id) = @_;
    return {
        jsonrpc => "2.0",
        error => {
            code => $error_code,
            message => $error_message,
        },
        id => $request_id,  # リクエストのIDを保持
    };
}

# Parse ErrorやInvalid Requestの場合のみnullを渡す

修正版では、リクエストから取得したIDをそのままレスポンスに含めるようにしました。

当時、バッチリクエストに対応している実装が少ないと聞いていたため、バッチ対応を意識して実装したつもりでしたが、肝心の仕様を誤解していたのは皮肉なことです。

実装時に「なぜわざわざIDを消すんだろう?」と疑問に思っていたことを覚えています。今になって思えば、その直感は正しかったのです。

他のRPCプロトコルとの比較

JSON-RPC 2.0のID必須仕様の特徴を理解するために、他のRPCプロトコルと比較してみましょう。

REST API

RESTful APIは、HTTPのリクエスト-レスポンスモデルに依存しています。

特徴:

  • 同期的な通信が基本
  • TCPコネクション自体がリクエストとレスポンスの対応を保証
  • アプリケーションレベルでのID管理は不要

エラーハンドリング:

  • HTTPステータスコード(200, 404, 500など)に依存
  • エラー詳細はレスポンスボディで自由形式(標準化なし)
1
2
3
4
5
6
HTTP/1.1 404 Not Found
Content-Type: application/json

{
  "error": "User not found"
}

XML-RPC

JSON-RPCの前身とも言えるプロトコルです。

特徴:

  • 同期通信が前提
  • HTTPリクエスト-レスポンスの1対1対応のみ
  • ID管理の仕組みなし

エラーハンドリング:

  • faultCodefaultStringのみ
  • コードの標準化なし(実装依存)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
<?xml version="1.0"?>
<methodResponse>
  <fault>
    <value>
      <struct>
        <member>
          <name>faultCode</name>
          <value><int>4</int></value>
        </member>
        <member>
          <name>faultString</name>
          <value><string>Too many parameters.</string></value>
        </member>
      </struct>
    </value>
  </fault>
</methodResponse>

gRPC

Googleが開発した高性能RPCフレームワークです。

特徴:

  • HTTP/2ベースで双方向ストリーミング対応
  • ストリームIDをHTTP/2のフレームレベルで管理
  • アプリケーション層での明示的なID不要

エラーハンドリング:

  • 16種類の標準ステータスコード(OK, CANCELLED, INVALID_ARGUMENT等)
  • 詳細なメタデータとスタックトレース対応
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// gRPCのエラーは構造化されている
rpc GetUser(UserRequest) returns (UserResponse) {
  option (google.api.http) = {
    get: "/v1/users/{user_id}"
  };
}

// エラー時
Status: INVALID_ARGUMENT
Message: "Invalid user ID format"
Details: [additional structured data]

比較表

プロトコルID管理多重化バッチ処理エラー標準化
JSON-RPC 2.0アプリケーション層で必須✓(標準コード)
REST API不要(HTTP依存)×△(実装依存)△(HTTPステータス)
XML-RPCなし××△(実装依存)
gRPCHTTP/2層で管理△(ストリーム)✓(16種類)

JSON-RPC 2.0の位置づけ

JSON-RPC 2.0は、以下の点で独自の強みを持っています。

1. シンプルさと柔軟性の両立

  • gRPCのようなProtocol Buffers不要
  • RESTよりも明確なメソッド呼び出し
  • 様々なトランスポート層で利用可能(HTTP、WebSocket、TCP等)

2. 非同期通信への適性

  • WebSocketとの相性が良い
  • IDによる明確なリクエスト-レスポンス対応

3. 通知(Notification)のサポート

  • IDを省略することで、レスポンス不要な一方向通信も可能
1
2
3
4
5
6
// 通知の例(IDなし)
{
  "jsonrpc": "2.0",
  "method": "notify_update",
  "params": {"status": "completed"}
}

実装時の注意点とベストプラクティス

JSON-RPC 2.0を実装する際の注意点をまとめます。

サーバー側の実装

1. リクエストのIDを可能な限り保存する

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// Node.jsでの実装例
function handleRequest(requestText) {
  let requestId = null;
  let request;
  
  try {
    request = JSON.parse(requestText);
    // パース成功時点でIDを取得
    requestId = request.id !== undefined ? request.id : null;
  } catch (e) {
    // Parse Error
    return createErrorResponse(-32700, "Parse error", null);
  }
  
  // バリデーション
  if (!request.jsonrpc || request.jsonrpc !== "2.0") {
    // IDが取得できていればそれを使う
    return createErrorResponse(-32600, "Invalid Request", requestId);
  }
  
  // 以降の処理でもrequestIdを保持し続ける
  // ...
}

2. エラーコードの標準を守る

JSON-RPC 2.0で定義されている標準エラーコード:

  • -32700: Parse error(JSONパース失敗)
  • -32600: Invalid Request(不正なJSON-RPCリクエスト)
  • -32601: Method not found(メソッドが存在しない)
  • -32602: Invalid params(パラメータが不正)
  • -32603: Internal error(サーバー内部エラー)
  • -32000-32099: サーバー定義エラー(独自エラー用)

3. バッチリクエストへの適切な対応

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
function handleBatchRequest(requests) {
  const responses = [];
  
  for (const request of requests) {
    // 通知(IDなし)の場合はレスポンス不要
    if (request.id === undefined) {
      processNotification(request);
      continue;
    }
    
    // 通常のリクエストはレスポンスを生成
    const response = processRequest(request);
    responses.push(response);
  }
  
  // レスポンスが0個の場合(全て通知)は何も返さない
  return responses.length > 0 ? responses : null;
}

クライアント側の実装

1. ユニークなIDの生成

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// シンプルなカウンター
let requestId = 0;
function generateId() {
  return ++requestId;
}

// またはUUID
import { v4 as uuidv4 } from 'uuid';
function generateId() {
  return uuidv4();
}

2. 非同期リクエストの管理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class JsonRpcClient {
  constructor(url) {
    this.url = url;
    this.pendingRequests = new Map();
  }
  
  async call(method, params) {
    const id = generateId();
    
    return new Promise((resolve, reject) => {
      this.pendingRequests.set(id, { resolve, reject });
      
      const request = {
        jsonrpc: "2.0",
        method: method,
        params: params,
        id: id
      };
      
      this.send(JSON.stringify(request));
    });
  }
  
  handleResponse(responseText) {
    const response = JSON.parse(responseText);
    const pending = this.pendingRequests.get(response.id);
    
    if (!pending) {
      console.warn(`Unexpected response with id: ${response.id}`);
      return;
    }
    
    this.pendingRequests.delete(response.id);
    
    if (response.error) {
      pending.reject(new Error(response.error.message));
    } else {
      pending.resolve(response.result);
    }
  }
}

3. エラーIDがnullの場合の扱い

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
handleResponse(responseText) {
  const response = JSON.parse(responseText);
  
  // IDがnullの場合は、特定のリクエストと対応付けできない
  if (response.id === null && response.error) {
    // Parse ErrorやInvalid Requestなど
    // 全体的なエラーとして扱う
    console.error("JSON-RPC protocol error:", response.error);
    // 必要に応じて全てのpendingリクエストを拒否するなど
    return;
  }
  
  // 通常のID付きレスポンス処理
  // ...
}

テストのポイント

1. ID保持のテスト

1
2
3
4
5
6
7
8
// テストケース例
test('should preserve request ID in error response', async () => {
  const response = await client.call('nonexistent_method', [], { id: 42 });
  
  expect(response.error).toBeDefined();
  expect(response.error.code).toBe(-32601);
  expect(response.id).toBe(42);  // IDが保持されていること
});

2. Parse Error時のnullテスト

1
2
3
4
5
6
7
8
test('should return null ID on parse error', async () => {
  const invalidJson = '{invalid json}';
  const response = await sendRawRequest(invalidJson);
  
  expect(response.error).toBeDefined();
  expect(response.error.code).toBe(-32700);
  expect(response.id).toBeNull();  // IDがnullであること
});

まとめ

IDを可能な限り保持する設計の意義

JSON-RPC 2.0の「エラーレスポンスでもIDを可能な限り保持する」という仕様は、以下の利点をもたらします。

  1. 非同期通信での確実な対応付け - 複数の並列リクエストでも、どのレスポンスがどのリクエストに対応するか明確
  2. トレーサビリティの向上 - ログやモニタリングでリクエスト-レスポンスペアを追跡しやすい
  3. バッチ処理の明確化 - 複数リクエストの処理結果を正確に識別できる
  4. デバッグの容易さ - エラー発生時でも、どのリクエストが失敗したのかすぐわかる

この設計思想は、非常に好感が持てるものです。

8年の誤解から学んだこと

私は8年もの間、この仕様を誤解したまま実装を公開していました。幸い、JSON::RPC::Specのバッチ処理機能を実際に使うことはありませんでしたが、もし本番環境で使われていたら、デバッグやトレースに大きな支障をきたしていたかもしれません。

今回の経験から学んだことは:

  • 仕様書は丁寧に読む - MUSTとSHOULDの違い、条件節の適用範囲を正確に理解する
  • 違和感は大切に - 実装時に感じた「なぜ?」という疑問は、立ち止まって考える価値がある
  • 定期的な見直し - たとえ公開済みのコードでも、機会があれば仕様を再確認する

これからJSON-RPCを使う方へ

JSON-RPC 2.0は、シンプルでありながら非同期通信やバッチ処理を想定した堅牢な仕様です。特にWebSocketを使ったリアルタイム通信や、マイクロサービス間のRPCには適しています。

実装する際は、今回解説した「IDを可能な限り保持する」という基本原則を理解した上で、以下の公式仕様を参照してください。

そして、私と同じような誤解をしないよう、特にエラーハンドリング部分は注意深く実装してください。


追記: JSON::RPC::Specの修正版は、CPANで公開されています。8年ぶりの更新となりましたが、より仕様に忠実な実装になりました。Perlを使っている方は、ぜひ活用してください。

comments powered by Disqus
Hugo で構築されています。
テーマ StackJimmy によって設計されています。