こんにちは。システム開発一部の法貴です。
当社のシステムは、社内外のさまざまなWeb APIと連携を行っています。このため、自社のAPIを外部に公開したり、他社のAPIを利用したりするような、API開発のプロジェクトが数多くあります。
今回はWeb API設計に携わるエンジニアならば、必ず理解しておいてもらいたいポイントについてお話しします。
通信エラーと二重更新のリスク
API連携をするとき、通信の安定性の高い専用線を使えることもありますが、インターネットを経由することも多くあります。
インターネットを経由する以上、通信経路中のどこかのサーバでエラーが発生したり、通信に時間がかかってタイムアウトが発生することは、避けられない前提として設計しないといけません。専用線などの信頼度の高い回線を使える場合でも、頻度は低くなりますが、通信エラーが発生する可能性は残ります。
APIは、APIサーバ側のデータを更新する更新系APIと、データを参照するが更新はしない参照系APIに大別できます。参照系APIであれば、通信エラーが発生しても、単にリトライすればいいだけです。何度APIを呼び出しても、APIサーバのデータは変わりませんから、呼び出し結果は変わらないからです。
更新系APIの場合はどうでしょう。通信エラーになったので、呼び出し元は、データを更新するという目的を達成できたのかどうか分かりません。
このとき、参照系と同じように気軽にリトライしてしまうと、APIサーバ側の仕様によっては、二重更新(二重執行)が発生する恐れがあります。
1回目の通信が、実は、APIサーバまでは届いており、その戻りの通信がエラーになったケースや、通信自体は成功していたが、時間がかかりすぎたために、通信経路中のいずれかのサーバがタイムアウトとして、レスポンスを待つことを打ち切ってしまうケースがあるからです。
このため、更新系APIをリトライしたいときは、更新系API側で、二重更新防止の仕組みがあるか確認しましょう。これはAPIのインターフェースを見るだけで簡単に見当がつきます。
トランザクションIDによる二重更新防止の設計
見分け方は、更新リクエストのパラメータにAPI呼び出し側で付番するトランザクションIDがあるかどうかです。これがあれば、APIサーバは、今送られて来たリクエストが、さっき処理したリクエストをリトライしたもの(再度処理してしまうと二重更新になってサーバ側のデータが呼び出し元が意図してない状況になってしまう)なのか、単に同じ処理をもう一度したいだけなのか(さっき買ったばかりのお菓子を、直後にもう一袋買いたくなることだってありますね)、判別できるようになります。
つまり、APIの呼び出し側では、同じリクエストをリトライするときは、同じトランザクションIDを使うようにしなければなりません。
リトライの度に新しいトランザクションIDを使ったら、APIサーバ側は、単に同じお菓子を連続で買いたくなっただけとみなし、2回目の更新をするでしょう。この結果が意図しないものだとしたら、これはAPIの呼び出し側のバグです。
APIサーバ側では、呼び出し元で付番するトランザクションIDを、データベースのテーブルの主キーとして、一意制約をかけることで、同じリクエストを2回処理できないようにします。このとき、トランザクションIDの他にタイムスタンプなどを加えた複合キーにしてしまうと、同じトランザクションIDであっても、複数回登録できるようになってしまうので、APIサーバ側のバグとなります。
この二重更新防止の仕組みは、更新系APIとしては当たり前の機能ですので、この仕組みがない場合は、他にも不具合が潜んでいる可能性が高く、システムの品質を判断するためのリトマス試験紙としても使えるでしょう。
二重更新エラー時のレスポンス設計
さて、ここからが本題になります。
呼び出し側で付番したトランザクションIDがすでにAPIサーバ側のデータベースに登録済みだったとき、APIはどのようなレスポンスを返すべきでしょうか。
素直に設計すると、一意制約エラーや、二重更新エラーなどの、エラーレスポンスになりそうです。実際、この設計で事足りることも多いです。
ただ、この設計だと困るケースが存在するのです。それは更新系APIのレスポンスで、更新の成功/失敗以外の何らかの情報を返している場合です。
例えば、サーバ側で付番する受付番号のようなものだったり、今回のお菓子購入で付与されたポイント額とポイント残高だったり、証券会社らしい例としては残りのNISA枠など、何かしらの情報を返しているケースは少なくないと思います。(それがREST原理主義者にとって気持ちのいい設計なのかどうかは別として)
そのようなケースで、1回目のリクエストのレスポンスが通信エラーやタイムアウトになって、API呼び出しをリトライした結果(※1)、二重更新エラーが返って来たら、どうやら1回目のリクエストがAPIサーバまで到達していていたようだということは察しがつくのですが、正常に更新が成功した時のレスポンスで受け取る予定だった情報が受け取れないという事態におちいるわけです。
1つの解決策は、呼び出し側で付番するトランザクションIDをリクエストパラメータとして、そのトランザクションがAPIサーバ側でどのように処理されたのかを返す、参照系APIを作ることです。そもそも二重更新エラーの発生時以外でも、このような参照系APIのユースケースがある場合は、この解決策で問題ないでしょう。ただし、この場合、二重更新エラーを受け取った呼び出し側は、自ら、結果確認のために、参照系APIを呼び出す必要があるため、一手間かかることになります。
別の解決策として、二重更新エラーのエラーレスポンス中に、そのトランザクションIDの1回目のリクエストの処理結果を含めるという案もあるでしょう。しかし、これは個人的な意見になるかもしれませんが、エラーなのに、正常時のレスポンスが含まれるというのは直感的なインターフェースとは言えないと思います。
そもそも、APIの呼び出し側としては、1回目のリクエストで成功したのか、リトライ時に初めて成功したのかは、どっちでも良くて、そのトランザクションIDについて、処理してもらうことと、その処理結果を知ることが関心事なわけです。
ということは二重更新エラーについては、エラーレスポンスを返すのではなくて、あたかもリトライ時に初めて処理が正常したかのように、正常時のレスポンスを返した方が、使いやすいインターフェースとなるかもしれないのです。(※2, ※3)
ここに記載した3つの解決策のどれが正解なのか、もしくはもっと別の解決策があるのかは、そのAPIのユースケースによって違ってくるでしょう。API設計者にとって大事なのは、API呼び出し側の立場になって使いやすいインターフェースを考えることであり、そこがこの仕事の面白いところです。
さいごに
今、金融業界では、エンベデッドファイナンス(組込型金融)という言葉が流行り始めています。また、国としてもそれを後押しするために、金融サービス仲介業という新しい制度も生まれています。エンベデッドファイナンスの実装にはAPIが不可欠なので、これから金融業界のエンジニアにとって、APIの設計はますます重要になってくるでしょう。証券業界のエンジニアの仕事もきっとますます面白くなりますよ!
※1.実際にAPI呼び出しのリトライ処理を実装するときは、どのようなエラーやレスポンスコードのときにリトライしていいのか、また、リトライ前に何ミリ秒待った方がいいかなど、利用するAPI側に詳細を確認するようにしてください。
※2.呼び出し元には正常レスポンスを返す設計にした場合でも、サーバ側のログには二重執行エラーを出力しておくと、APIの稼働状況の分析に役立つでしょう。
※3.同じリクエストをしたとき、毎回必ず同じ結果になることを「冪等性(べきとうせい)」と呼ぶそうです。サーバ側のデータを更新しない参照系のAPIであれば、基本的に冪等性のあるAPIとなるでしょう。一方で、更新系のAPIであれば、呼び出される度にサーバ上のデータが更新されるため、基本的には冪等性のないAPIとなりますが、トランザクションIDを用いて二重更新防止を行った上で、二重更新エラーのときに初回のデータ更新時と同じ正常レスポンスを返すようにすれば、更新系APIであっても冪等性のあるAPIと言えると考えています。同じリクエスト(同一トランザクションIDであることが条件)を複数回送信しても、サーバ上のデータ更新は初回のみであり、二回目以降はサーバの状態やレスポンスが変化しないからです。一般的に、APIが冪等性を持っていると、呼び出し側で考慮すべき前提事項が減って、使いやすいAPIとなります。