APIのエラーハンドリングを見直そう

ここ数ヶ月にわたって、WebPayはAPIのエラーにまつわる変更を少しずつ行ってきました。 それに付随してドキュメントも拡張しましたが、変更の背景について十分に説明できていない部分がありました。 この記事では、最近のエラーに関連した変更の背景を紹介し、今後どのようにエラーをハンドルすべきか説明します。

記事の内容は執筆時点のものであり、今後同じようにエラーやAPIの変更を行うことがあります。 変更があっても記事の内容はその時点の内容を保持し、ウェブサイトのドキュメントのみ更新します。 必ずウェブサイトのドキュメントを合わせて参照し、手元で動作確認を行ってください。

エラーはなぜ起きるのか

WebPayのAPIは、リクエストされた操作ができなかったときにエラーを返すように設計しています。 可能なかぎりエラーにならないような設計、実装を心がけていますが、エラーは絶対に避けられません。 例えば、決済を行うリクエストに金額が指定されていなかったら決済はできません。

必須のパラメータがすべて揃っていても、エラーになる場合があります。 例えば指定されたカードがカード会社と契約している利用限度額をオーバーしているために決済できない場合です。 指定されたカードで決済可能かどうかはカード会社にしか分からないので、これを未然に防ぐことはできません。

WebPayのサーバや、WebPayが決済に利用しているシステムの不調でリクエストが遂行できないこともあります。 このようなサービスの停止は最小限にするよう努力していますが、カード決済にはWebPayだけでなく多くのシステムが関与しているため、 ゼロにすることはやはり困難です。

それ以外にも、APIを利用するサービスが稼動しているマシンの不調でネットワーク接続ができなかったり、ネットワークが遅くてタイムアウトすることもあります。 WebPayがエラーレスポンスを返す以前に、クライアント側の処理がエラーになります。

いかに注意を払ってAPIを利用していても、カード決済失敗とサービス利用不能のエラーは必ず発生します。 そこで、すべての予想されるエラーをカバーする徹底的なエラーハンドリングが必要なのです。

WebPayのAPIの仕組み

ここからはAPIエラーのドキュメントに沿って解説します。 ドキュメントを合わせてご覧ください。

WebPayのAPIの仕組みを理解すると、エラーがどのように返されるのか分かりやすくなります。 具体的な操作によって多少の差異がありますがAPIはおおよそ次のような手順でリクエストを処理します。

  1. APIキーからリクエストしたユーザを特定する
  2. リクエストの内容を検証する
  3. 処理に必要なオブジェクトを取得する
  4. 処理を実行する
  5. 結果をレスポンスする

まず、1でどのユーザの操作かを判断します。 ここでユーザが特定できないと、Unauthorizedエラーになります。 主な原因はAPIキーが指定されていなかったり、間違っていたりというものです。

2ではリクエストの必須パラメータで不足しているものがないか、無効な値が指定されていないかを確認します。 この確認で間違いがみつかると、InvalidRequestエラーになります。 エラーの原因はエラーレスポンスに記載されていますので、該当箇所を修正して再度リクエストしてください。

3は、課金の返金であれば返金対象の課金のデータ、顧客に対する課金の作成であれば顧客のデータを取得するステップです。 操作に必要なオブジェクトが削除されていたり、そもそも存在しない場合は、NotFoundエラーになります。

4は実際に課金を行ったりするステップです。 ここでカード会社との通信を行いますが、その際にエラーになるとCardErrorが返ります。 CardErrorはさらに細かく分類されています。この分類はcodeプロパティで確認できます。

5は処理の結果をレスポンスするだけで、ここで新しくエラーが発生することは稀です。

サービスの障害によるエラーはステップ5を含め、どの段階でも発生し得ます。 障害が原因で処理できなかった時はAPIErrorが返ります。

このように、以前から行ってきたエラー種別(typeプロパティ) による分類は、どの処理でエラーが発見されたかと強く結び付いていました。

新しいエラーの分類

しかし、 APIを利用するサービスが興味があるのは、どこでエラーになったかではなく、エラーにどう対処すればいいか です。 基本的な対処方針はドキュメントで案内してきましたが、そのための記述が複雑になってしまう部分がありました。

主な理由はCardErrorにあります。 カード会社との通信中にエラーが検出された場合はCardErrorになりますが、CardErrorが起きる原因は様々です。 大抵は入力されたカード情報に誤りがあったり、そのカードが限度額不足や未払などの原因で利用できないといった、購入者に起因するものです。 しかし顧客にカードが紐付いていなかったり、カード会社のシステムに障害が起こっている場合もCardErrorになります。 codeプロパティに記載された細かい分類でどの原因かを判別できますが、実装が複雑になります。

そこで最近、誰がエラーの原因と推測されるか、という新しいエラー分類の軸を導入しました。 これを「エラーの原因」と呼んでおり、caused_byプロパティで確認できます。 ドキュメントのエラーの原因の項に詳細な説明があります。

現時点では、エラーは4つの原因のいずれかに分類されます。

  • buyer: 購入者。カード情報を入力して支払いをしようとした利用者が原因になっている場合
  • insufficient: パラメータの不足。指定したパラメータに問題がある場合
  • missing: オブジェクトなし。指定されたオブジェクトがみつからない場合
  • service: サービス。WebPayまたはカード会社のサービスの障害が原因の場合

先程のCardErrorの例では、顧客にカードが紐付いていない時はmissing、カード会社のシステム障害はserviceとなります。 より直感的な分類になっています。

この分類を使うと、buyerが原因ならエラーメッセージを購入者に表示してカード情報の更新や再入力をうながす、 insufficientmissingならエラーを記録して購入者にはサービスに問題があり利用できない旨を示す、 serviceならエラーを記録して一時的に利用できないので時間をおいて再度試みてほしい旨を示すといったように、 原因に応じた処理を単純に記述できます。

insufficientmissingはプログラムのバグか運用のミスに起因するので、エラーを記録して原因を修正するようにしてください。 問題を放置すると、ユーザがいつまでも決済できず、売上の機会を逃したり、決済できていないのにサービスを利用されることがあります。

serviceに起因するエラーは通常ごく短時間(1分以内) で回復しますが、まれに自動回復せず、長時間(数十分から数時間) の障害になることがあります。 少し待っても回復せず、原因が不明瞭な場合はお問い合わせください。 なお、こちらのエラーについては発生頻度が稀であり、常に開発チームに通知されるようにしています。

エラーハンドリングの実装方法

最後に、この新しいエラーの分類を利用したエラーハンドリングの方法を説明します。 Rubyライブラリ2.2.1以上でのみ利用可能な記法なので、以前のバージョンをご利用の場合はアップデートしてください。

次の例ではlog()でログファイルに記録、render()でそのメッセージを購入者のブラウザに表示するという想定で記述しています。 ご利用のフレームワーク等に合わせて適宜変更してください。

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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
require 'webpay'
webpay = WebPay.new('test_secret_eHn4TTgsGguBcW764a2KA8Yd')

# APIの言語をサイトの言語と合わせておく
# この言語でエラーメッセージが表示される
webpay.set_accept_language('ja')

begin
  # API リクエスト
  webpay.charge.create(
     amount: 400,
     currency: "jpy",
     card:
      {number: "4242-4242-4242-4242",
       exp_month: 11,
       exp_year: 2019,
       cvc: "123",
       name: "KEI KUBO"},
     description: ""
  )
rescue WebPay::ErrorResponse::ErrorResponseError => e
  case e.data.error.caused_by
  when 'buyer'
    # カードエラーなど、購入者に原因がある
    # エラーメッセージが購入者に分かりやすいものになっているので
    # そのまま表示する
    render(e.data.error.message)
    # 頻繁に発生し、サービス側で対応の必要がないので、ロギングは必須ではない
    # サポート目的でロギングする場合はINFOレベル以下が良い

  when 'insufficient', 'missing'
    # 実装ミスに起因する
    # ERRORレベルでログに残し、すぐに対処する
    error = e.data.error
    log(ERROR, "Invalid request error: message:#{error.message} " +
        "caused_by:#{error.caused_by} " +
        "param:#{error.param} " +
        "type:#{error.type}")

    # 詳細を購入者に告知する必要はないので、固定のメッセージを表示する
    render('サービスの障害により、現在ご利用いただけません。')

    # 大抵の場合 insufficient と missing は同様に扱えるが
    # 運用上 missing の発生を避けられない場合は個別に対処する

  when 'service'
    # WebPayに起因するエラー
    log(ERROR, "WebPay API error: message:#{e.data.error.message}")
    render('サービスの障害により、一時的にご利用いただけません。" +
        "少し時間をおいて再度お試しください。')
  else
    # 未知のエラー
    # 新しいエラーの原因が追加されたが、対応していない場合などが想定される
    error = e.data.error
    log(ERROR, "Unknown error response: message:#{error.message} " +
        "caused_by:#{error.caused_by} " +
        "type:#{error.type}")

    # 原因が予測できないので固定のメッセージにする
    render('サービスの障害により、現在ご利用いただけません。')
  end
rescue WebPay::ApiError => e
  # APIからのレスポンスが受け取れない場合。接続エラーなど
  log(ERROR, "API request is not completed: #{e}")
  render('サービスの障害により、現在ご利用いただけません。')
end

これは全体のエラーハンドリングの基本となる記述です。 個別の箇所で特定のエラーが発生することが始めから予期されている場合は、それに応じた処理を別途行います。

ここではRubyのライブラリで解説しましたが、他の言語のライブラリでも同等の記述が可能です。 それぞれ次の記事を参考にしてください。

独自に実装したライブラリをご利用の場合は、エラーレスポンスのcaused_byプロパティに基いて分岐することで同等の処理になります。

エラーハンドリングを見直そう

今回解説した新しいエラーの分類、これに基いたエラーハンドリングの方法は最近導入されたものです。 以前からWebPayをご利用のサービスでは、まだ対応が済んでいないかもしれません。

以前のものでも動作に支障はありませんが、正しいエラー処理が複雑になってはいないでしょうか。 いま一度、意図通りのエラー処理ができているかを確認し、この機会に新しい方法の導入をご検討ください。