TT Lab
はじめる
学ぶ 学習パス コース

EAI 中間層をつくる

タイムアウトは失敗ではなく不明だ

TT Labで続きを見る

一言でいうと

同期中継とは、電文を受け取って対象システムを呼び出し、その結果を標準応答コードに変換して返す仕事です。最も重要な区別は、「失敗した」と「わからない」です。接続すらできなかったなら、送っていないことが確実ですが(E902)、送ったあとに答えを受け取れなかったなら、対象が処理したかどうかは誰にもわかりません(E901)。この2つを同じコードにまとめてしまった瞬間から、二重振込が始まります。

なぜ必要なのか

チャネル(インターネットバンキング)が勘定系を直接呼び出すと、チャネルは勘定系のアドレス・HTTP規約・エラー形式をすべて知らなければなりません。勘定系がエラーを422 {"result":"INSUFFICIENT_FUNDS"}で返そうが200 {"code":"E-17"}で返そうが、チャネルごとに解釈のコードが別々にでき、対象システムが変わるときにはチャネル10個を直すことになります。中継層がこの解釈を1か所で引き受け、チャネルには標準応答コード1つだけを返します。残高不足は、どの対象から来てもB201です。

ところが中継が挟まると、失敗の地点が1つ増えます。チャネル → ハブ → 勘定系の区間のどこでも止まる可能性があり、止まった場所によって意味がまったく違います。このモジュールの勘定系フィクスチャは、現実と同じように作ってあります。遅くても、処理は最後までやり遂げます。ハブが2秒で諦めても、勘定系は4秒目に残高を引き落とします。このときハブがチャネルに「失敗」と答えると、チャネルは再送し、お金は二重に引かれます。

どう動くのか

リクエスト1件の道のり。① TCPで長さの4バイトをちょうど読み、その長さ分だけ読み直します(モジュール1)。② ヘッダーをパースし、形式が誤っていればE102を返します。③ 取引コードが中継対象でなければE101を返します(ルーティング、モジュール2)。④ 本文をレイアウトどおりにJSONに変換します(モジュール3のルールで、今は共通ライブラリlhconvを使います)。⑤ 勘定系のPOST /v1/transfersを呼び出します。GUIDを本文とX-GUIDヘッダーに載せます。⑥ 結果を標準コードに変換してレスポンス電文を作ります(正常なときだけ、レスポンス本文の45バイトが付きます)。

結果を4つに分けます。HTTPセマンティクス(RFC 9110)では、2xxはリクエストが正常に処理されたという意味、4xxはリクエスト側の問題、5xxはサーバーが処理できなかったという意味です。勘定系は、業務上の拒否を422(RFC 9110 15.5.21、リクエストの形式は理解したが処理できない内容)で返し、本文のresultで理由を区別します。そのため変換表は、ステータスコードと業務コードを両方見ます。

状況 わかること 標準コード
200 処理された 0000
422 + INSUFFICIENT_FUNDSなど 拒否された(お金は動かない) B201–B203
500 対象が処理できなかったと言った E500
レスポンスを待つ間にタイムアウト 送った。処理されたかはわからない E901
接続拒否・接続タイムアウト 送れなかった(未送信が確定) E902

タイムアウトには2種類あります。接続を確立する間のタイムアウトと、リクエストを送ったあとレスポンスを待つ間のタイムアウトは、意味が正反対です。前者はリクエストのバイトが1バイトも出ていないので、再送しても安全です。後者はリクエストがすでに対象に届いています。Python 3.12のurllib.request.urlopenは、この2つを別の例外として出します(この過程で実測)。接続段階の問題はURLErrorで包み(reasonがConnectionRefusedErrorやTimeoutError)、レスポンスを待つ間のタイムアウトは、包まれていないTimeoutErrorとして上がってきます。例外を1つにまとめて捕捉し、同じコードに変換すると、この区別が失われます。

タイムアウトバジェットは、内側に行くほど短くなければなりません。チャネルが10秒待つのに、ハブが勘定系を15秒待つと、チャネルはすでに諦めて再送しているのに、ハブはまだ最初のリクエストを抱えたままです。ハブの結果は誰にも受け取られません。そのためチャネル > ハブ > 対象の順にバジェットを短くし、ハブは自分のバジェットの範囲内で必ず何かを返します。わからなければ「わからない」(E901)と答えます。

接続ごとに別々に処理します。リクエスト1件を処理している間は次の接続を受け付けないサーバーは、遅い振込1件のせいで、後ろのすべての振込を行列にして待たせます。Python標準ライブラリのsocketserver.ThreadingTCPServerは、接続ごとにスレッドを1つずつ起動します。スレッドは制限なく増えうるので、モジュール11で、対象ごとの同時処理数の上限(バルクヘッド)を付けます。

現場での姿

最も高くつく事故は、E901を失敗として処理したチャネルです。「応答タイムアウト。もう一度お試しください」という画面の文言1つで、顧客がボタンをもう一度押し、勘定系には振込が2件残ります。そのため、結果未確定のレスポンスには、再送禁止と結果照会がセットでつきます(モジュール8)。2つ目は、対象システムのエラーメッセージをそのままチャネルに流すハブです。勘定系の内部エラー文言(スタックトレース・テーブル名)が顧客の画面に表示され、チャネルは対象ごとに違う文言を解釈するコードを積み上げていきます。3つ目は、ローカルではいつも動いていた中継が、本番で電文を半分ずつ失うことです。長さの分だけ読み切らず、recv1回で済ませたコードが原因です。

次のラボですること

勘定系フィクスチャを起動して直接呼び出し、インターフェース仕様書を見て応答コードの変換表を書きます。そのあと中継サーバーrelay.pyをステップごとに育てます。断片に分かれて届く電文を最後まで読む骨組み、正常な振込の中継、業務エラーの変換、読み取りタイムアウト(E901)、接続失敗(E902)、並行処理の順です。採点ツールはrelay.pyを直接起動し、勘定系フィクスチャの呼び出し統計で何回呼んだかまで確認します。