15
12

More than 3 years have passed since last update.

【Rails】Payjpを使って例外処理について考えてみる

Last updated at Posted at 2019-09-11

注意

初心者のにわか仕込みによる振り返りと整理用のメモです。
間違っているところや、言い回しが独特すぎて変なところなど、沢山あると思います。
皆さま指南いただけると嬉しいです。

はじめに

本番環境にデプロイしたあと、以下のような画面に悩まされたことはないでしょうか。
68747470733a2f2f71696974612d696d6167652d73746f72652e73332e616d617a6f6e6177732e636f6d2f302f3237303435362f33363234393664392d313736632d626134662d353066662d3963623238303230343062392e706e67.png

開発環境ではデバッグ方法も揃っており、開発者なら難なく解決できることが多いと思います。
ですが、本番環境にデプロイして「さぁ、使ってみるぞ!」と、触り始めた瞬間この画面に出くわすと、何が悪いのかわからず迷走しちゃいます。開発者じゃなくユーザなら、途方にくれるか、さよならです。

今回は、学習中に使ったpayjpと言うとクレジットカード決済用の外部APIを例に、例外処理について振り返りながら整理してみたいと思います。

説明の流れ

ざっくり二本立ての構成です。

[例外処理全般]
  ↓
[Payjpの例外処理]

例外処理全般の整理

整理のために先にこっちから取り掛かります。

例外の種類

コーディングバグであれば、きちんとテストを行って取り除いていく必要がありますが、それだけでは取り除けないものは例外処理を適切に行う必要があります。
例外は大きく「業務エラー」と「システムエラー」の2つに分類されるようです。

業務エラー
 ユーザの入力ミスなどによって発生する例外
 該当箇所及び修正依頼のメッセージを画面上に出力してあげるのがユーザーフレンドリーかな。。
 入力ミスするたびに落ちるシステムなんて使いたくないですよね。。
システムエラー
 サービスやDB停止などの障害、ネットワーク障害などによって発生する例外
 ユーザではどうする事も出来ないので、デフォルトのエラー画面にするのか、システム管理担当に連絡を促すようなメッセージを画面上に出してあげるのがユーザフレンドリーなのかな。。

Railsでの例外処理

RailsではRubyの例外処理の仕組みを利用して、例外処理を記述する事が可能です。
例外処理を正しく組み込むことで、ユーザーフレンドリーなシステムに近くはず・・・!

例外処理の書き方

シンプルに書くとこんな感じです。
例外検知対象の処理をbegin以降に記述します。
それらの処理の後にrescue句で例外が検知された場合の処理をを記述。最後はendで締めくくり。なお、StandardErrorはrescue => eと記載を省略する事が可能です。

sample.rb
 begin
   # 例外検知を行う処理を記載
 rescue StandardError => e
   # 該当の例外が検知された場合の処理を記載
 end

Rubyに事前定義された例外クラス

Rubyでは下図のように沢山の例外クラスが定義されています。
このうち私がrescueで捕捉するのはStandardError配下の例外クラスです。これらのクラスがアプリケーションに起因する通常の例外を扱っています。
※逆にそれ以外特にExceptionなどをrescueで捕捉すべきでは無いようです。

出典:Rubyの例外
rescue.jpeg

例外発生時に拾える情報

実際にrescueで捕捉されたオブジェクト"e"の中身って何があるんだろ?って思いませんか?
StandardErrorのドキュメントには有益な情報がなかったのですが、始祖であるExceptionクラスの説明に詳細な記載がありました。一部抜粋します。詳細は出典を確認ください。
※実際コンソールで確認したところ、よくターミナルで見かけるものが沢山な感じ

インスタンスメソッド                                             説明      
==   自身と指定された other のクラスが同じであり、 message と backtrace が == メソッドで比較して 等しい場合に true を返します。そうでない場合に false を返します。
backtrace バックトレース情報を返します。
backtrace_locations バックトレース情報を返します。Exception#backtraceに似ていますが、 Thread::Backtrace::Location の配列を返す点が異なります。
cause self の前の例外(self が rescue 節や ensure 節の中で発生した例外の場合、 その前に発生していた元々の例外)を返します。存在しない場合は nil を返し ます。
exception 引数を指定しない場合は self を返します。引数を指定した場合 自身のコピー を生成し Exception#message 属性を error_message にして返します。
full_message エラーメッセージを表す文字列を指定します。
inspect 例外の整形された文字列を返します。
message self のクラス名と message を文字列にして返します。
to_s エラーメッセージをあらわす文字列を返します。
set_backtrace バックトレース情報に errinfo を設定し、設定されたバックトレース 情報を返します。

出典:Ruby 2.6.0 リファレンスマニュアル:Exceptionクラス

【注意】取得情報の取り扱いについて
捕捉した例外から拾える情報にはアプリケーションにとって重要な情報が含まれている場合もあります。
そのため、不適切なエラーメッセージの表示は、セキュリティリスクを増大させる可能性を孕んでいます。表示しようとするメッセージの内容、詳細度、出力先(ログで良いのでは?)などを慎重に考えながらコーディングする必要がありそうです。

例外の捕捉順序

例外は上から順に捕捉されて行きます。
そのため、下記の様に親子関係のある例外クラスをrescueで列挙する際、親クラスを先に書くと、後に続く子孫クラスは捕捉されなくなるので注意が必要です。

sample.rb
 begin
   # 例外検知を行う処理を記載
 rescue StandardError => e
   # 任意の処理
 rescue ArgumentError => e
   # 任意の処理  ← 全てStandardErrorに吸い取られて、この処理が実行されることは無い!!
 end

独自例外クラスの定義

前項までは定義済みの例外クラスについて触れましたが、既存のStandardErrorクラスを継承させる事で、私たちでも独自の例外クラスを作成する事が可能です。
頭の整理も踏まえて、ちょっと試してみました!

① 独自例外クラス
今回の主役、独自に定義した例外クラスです。
前項までに紹介したエラー情報以外の情報も独自に定義することが可能です。

lib/exceptions.rb
module Exceptions
  class SampleError < StandardError
    # 独自に定義したいエラー情報があれば定義
    attr_reader :code,:message
    def initialize(code,message)
      # インスタンス変数に値を格納する事でrescueで捕捉した「e」に内包された形で参照出来る様になるみたいです。仕組みは謎。
      # 例)e.code/e.message
      @code = code
      @message = message
    end
  end
end

※独自例外クラスの置き場所は諸説(?)あるらしいです。独自調査(?)された方がいました。
参考:【Rails】独自例外の置き場所(配置ディレクトリ)について

② 例外を発生させるモジュール
例外を発生させたい場所でraiseする事で、意図的に例外を発生させる事ができます。

app/controllers/concerns/sample_service.rb
module SampleService
  extend ActiveSupport::Concern
  #独自例外クラスを読み込む
  require "#{Rails.root}/lib/exceptions.rb"
  def sample_service(params)
    if params == 1
      # raiseを使用して意図的に例外を発生させます。
      # この時に独自例外クラスをnewする事で、独自例外クラスで定義した項目に値渡しが可能です。
      raise Exceptions::SampleError.new('000','sample_serviceでエラーが発生しました')
    end
    return params
  end
end

③ 例外を捕捉するモジュール
実際にrescueを使って例外を捕捉してみましょう。

app/controllers/samples_controller.rb
class SamplesController < ApplicationController
  include SampleService
  def sample_action
    begin
      sample_service(1)
    rescue Exceptions::SampleError => e
      # 実際に定義したエラーが捕捉されたかエラー出力してみる
      logger.error "[errcode:#{e.code}]#{e.message}" 
      # フラッシュメッセージに格納して画面にも出力してみる
      flash.now[:alert] = "[errcode:#{e.code}]#{e.message}" 
      # 直前のアクションを描画する(任意のアクションを指定)
      render :previous_action
    end
  end
end

本当に例外が発生するか、試しに実行してみました。
お、ちゃんとコンソールログにエラーメッセージ吐かれてます。
Image from Gyazo
binding.pryしてみたら、値もちゃんと拾ってきてるね!
素晴らしい!
Image from Gyazo

独自例外クラスを使う時って?

今回はお試しで独自の例外クラスを作ってみましたが、普段は上記③のみを扱う機会が多いと思います。
そもそも、プロジェクト内に閉じた例外処理であれば、独自の例外クラスを定義するよりも、バリデーションや事前チェックなどでハンドリングしてあげるのが良いのかなと思います。
わざわざ例外作って、都度例外捕捉していくのは面倒くさそう。。

参考:そもそもRailsでカスタム例外を定義すべき機会は少ない

例外処理で使えるメソッド

例外処理を記述する際に使えるメソッドに以下があります。
発生した例外の種類によっては、これらのメソッドを利用して対処を記述すると非常に効率が上がるものもあるかと。

メソッド                  説明
retry   例外処理においてretryを記述することで、再度beginから処理が実行される。
無限ループに注意。
ensure   例外発生したとしても必ず行う処理を記述する。ファイルクローズなどの後処理など?

コーディング例

retry(リトライ)

ちょっと使いどころを考えてみましたが、基本は”システムエラー”に属するものが対象かなと思います。
入力ミスなどに対しリトライかけられたら、むしろ嫌がらせ。
また、組み込む時はリトライをかけても冪等性が保たれるか、二重実行時の対策が取られていることが前提でしょうか。
リトライかける度に状況を悪化させていたら元も子も無いですしね。

【利用シーンの想定】
一時的なネットワーク障害などを起因にしたものなら、リトライすれば上手くいったり、処理が成功していたかの確認(処理済みであるエラーが起こるなど)が出来そうです。

Payjp振り返る前にPayjpのAPIでタイムアウト起こしてみる(構成力のなさ笑)

app/controllers/samples_controller.rb
class SamplesController < ApplicationController
  require 'payjp'
  def sample_action
    #payjpの秘密鍵
    Payjp.api_key = 'sk_test_xxxxxxxxxxxx'
    #payjpのタイムアウト時間、問答無用でタイムアウトさせてみる
    Payjp.read_timeout = 0
    #例外対象処理の実行回数をカウントするための変数を用意
    try = 0
    begin
      #対象処理より前にカウントアップ
      try += 1
      #対象処理記述。参照しているだけなので何回リトライしても問題ないよね。
      @customer = Payjp::Customer.retrieve(current_user.card.customer_id)
    rescue Payjp::APIConnectionError => e
      if try < 5
        #ログ出してみる
        logger.error "sample_actionにおいてリトライが発生しています。リトライ回数:#{try}"
        #即刻リトライでは上手くいかないかもなので少し待つ
        #ここでリトライの度に指数関数的に待ち時間を増やして大規模障害時のアクセス不可軽減を考えるのもあり
        sleep(5)
        #リトライ実行!これだけ!
        retry
      else
        #諦める。そして、任意の例外処理に移る
      end
    end
  end
end

binding.pryかけて、適当なコンソール画面から実行してみました。
(だから、Controller名が変でも気にしないでください)
行けてるね。
Image from Gyazo
※リトライなどの管理は便利なgemが存在するようです。
参考:Retriable

ensure(えんじゅわ?)

railsでのensureの使いどころ分かりませんが、夜間バッチとかで例外発生後の後処理ミスると翌日大障害を引き起こすなんてことも。と言うか、これ関連は割と沢山痛い目にあいました。

【利用シーンの想定】
ファイルopen/closeぐらいしか思いつきません。。。
ただFile.open("sample.text") do | file |で開いた場合、close自体が必要じゃないみたいです。だとすると、こんな感じでしょうか。

app/controllers/samples_controller.rb
class SamplesController < ApplicationController
  def sample_action
    begin
      file = File.open("#{Rails.root}/public/text/sample.text","w")
    rescue IOError => e
      #任意のエラー処理
    ensure
      if file != nil
        #例外が発生したとしてもファイルを確実にクローズさせる
        file.close
      end
    end
  end
end

コチラもbinding.pryでお試し。面倒なのでraiseで例外強制発生させました。
Image from Gyazo
こう見るとraiseは独自例外クラスを発生させる以外にも、テストで重宝しそうですね。

Payjpを振り返る

独自例外クラスで説明した①、②の処理がPayjpにも組み込まれており、③を書くだけで例外捕捉が可能になっています。どういったものがあるのか、どの様に③を書けば良いのか?少し整理してみました。

Payjpの独自例外クラス

Payjpで独自に定義された例外クラスは包括クラス含めて6種類あります。
業務エラーと呼べるのは、CardErrorとInvalidRequestErrorあたりでしょうか。

例外クラス                                          説明
PayjpError   独自例外クラスの親クラス
独自例外クラスを包括的にrescueする場合に利用
(下記5種の独自例外を個別にrescueせず、まとめて捕捉したい場合)
CardError   何らかの理由で処理が拒否された場合に発生する
InvalidRequestError 無効なパラメーターがPayjpのAPIに渡された場合に発生する
AuthenticationError PayjpのAPIによる認証に失敗した場合に発生する
APIConnectionError Network関連でエラーが発生した場合に発生する
ex1:PAY.JPが障害中などでサーバーとの接合が切れた場合
ex2:api.pay.jpの名前解決に失敗した場合
ex3:リクエストからレスポンス受信までに90秒以上経過した場合
APIError   上記以外の特殊なケースの例外
ex:レスポンスのボディがJSON形式でない場合(PAY.JP障害時など)、応答のHTTPステータスが400,401,402,404以外の場合

※APIConnectionErrorのex3のパターンでは、リクエストが成功している可能性があるため、成否を確認する必要がある様です。また、レスポンスのタイムアウト時間は Payjp.read_timeout = 100の様に任意で設定が可能です。値を0にすれば、即APIConnectionError起こせ流のは前項で実証済み!笑

sample.rb
require 'payjp'

Payjp.api_key = 'sk_test_xxxxxxxxxxxx'
Payjp.open_timeout = 30 
Payjp.read_timeout = 90 # ← gemのreadmeにあるコレ !!!!

charge = Payjp::Charge.create(
  :amount => 3500,
 #以下略#

出典:Payjp:APIリファレンス(例外ハンドリング例:Ruby)

Payjp独自クラスの親子関係

概要でも少し触れていますが、Payjpの独自クラスは以下の様な親子関係となっていました。
この親子関係にしたがって、rescueの記述順を決めましょう。

<Payjpエラーの親子関係>
 StandardError(rescueではクラス名省略化)
   ∟ PayjpError
     ∟ Payjp::CardError
     ∟ Payjp::InvalidRequestError
     ∟ Payjp::AuthenticationError
     ∟ Payjp::APIConnectionError
     ∟ Payjp::APIError

取得可能なエラー情報

取得可能なエラー情報について公式のAPIリファレンスに記載があります。
例外によっては、返却値が空のものもある様です。独自に定義された項目には以下のものがあるようです。(githubから拾ってみたので間違ってるかも)

なお、適当に例外(今回はPayjp::InvalidRequestError)を発生させて中身を覗いてみました。例として追記します。json_body使えば包括してエラー取得できそうですね。

項目                         取得例
http_status e.http_status => 404
http_body e.http_body => "{\n \"error\": {\n \"code\": \"invalid_id\",\n \"message\": \"No such customer: cus_87b0c52e4b3a97a9e6174245e0db\",\n \"param\": \"id\",\n \"status\": 404,\n \"type\": \"client_error\"\n }\n}"
json_body e.json_body => {:error=>{:code=>"invalid_id", :message=>"No such customer: cus_87b0c52e4b3a97a9e6174245e0db", :param=>"id", :status=>404, :type=>"client_error"}}
message   e.message => "No such customer: cus_87b0c52e4b3a97a9e6174245e0db"
code 直接指定だとエラー
e.json_body[:error][:code]=>"invalid_id"
param e.param=> "id"
type 直接指定だとこちらもエラー
e.json_body[:error][:type]=>"client_error"

ちなみに、APIリファレンスには下記の記載があります。上記の取得例を見ても分かるようにcustomer_idなど、表示レベルが詳細過ぎるため画面には表示させない方がいいですね^^;

エラーレスポンス “error” 内の “message” にはエラーメッセージが含まれます。エラーメッセージは事業者向けの内容となっており、エンドユーザーへ提示する内容として利用することを推奨しておりません。

参考:APIリファレンス:Error

Payjpの例外処理記述例

今までの知識を総動員して、実際に例外処理を記述。
実際にスクールの課題でカリカリ書いてる時よりは、知識量増えたような・・・

①PayjpErrorを用いて包括的に例外処理する
エラーによって処理に違いが無い場合は、こちらでも良いかと思います。
ただし、例外によってはレスポンスが無い場合もあるので、エラー処理の書き方は要注意かもしれません。
例外処理による二重障害とか嫌ですね・・・。

sample.rb
  #payjp用のapi_key
  Payjp.api_key = Rails.application.credentials.payjp[:api_secret_key]

  begin
    @customer = Payjp::Customer.retrieve(current_user.card.customer_id)
  rescue Payjp::PayjpError => e
    @message = "このカードはご利用になれません。お手数ですが、窓口までお問い合わせください。"
    render :action
  end

※エラーの返却値を例外処理で使わないのは、あまり良く無いみたいですね。
ただ、何が返ってくるか分からないので、汎用的なメッセージ・・・アカン定型的パターンか笑。

②例外処理を個別に記述する
ある程度例外によって処理を変えたり、リトライを組み込んだりして、こねくり回してみる。

sample.rb
  #payjp用のapi_key
  Payjp.api_key = Rails.application.credentials.payjp[:api_secret_key]
  #リトライ回数の制御に使用
  try = 0
  begin
   #実行のたびにカウントアップ(リトライ数をカウントする)
   try += 1
   #例外を捕捉したい処理。参考として顧客情報の参照APIを拝借
   @customer = Payjp::Customer.retrieve(current_user.card.customer_id)
  rescue Payjp::CardError => e
    body = e.json_body
    @message = "このカードはご利用になれません。エラーコードを添えてお問い合わせください。 エラーコード:[#{body[:error][:code]}]"
  rescue Payjp::InvalidRequestError => e
    body = e.json_body
    @message = "入力いただいた情報に誤りがあります。今一度ご確認をお願いします。[対象項目:#{body[:error][:param]}]"
    logger.error body[:error][:message]
  rescue Payjp::AuthenticationError => e
    @message = "システムエラーが発生しております。エラーコードを添えてお問い合わせください。[ステータス:#{e.http_status}]"
  rescue Payjp::APIConnectionError => e
    #ネットワーク障害なら復活するかもってことで数回リトライしてみる
    if try < 5
      logger.error "APIConnectionErroによりリトライが発生しています。リトライ回数#{try}"
      sleep(5)
      retry
    end
    #諦めるしかない。諦めよう。タイムアウトとかのエラーはレスポンス無いので汎用メッセージ。
    @message = "システムエラーが発生しております。窓口までお問い合わせください。"
  rescue Payjp::APIError => e
    # エラーの発生方法が不明のため省略
  ensure
    # クレジットカード情報取得は成否に関わらず同じ画面表示想定としてコチラに書いてみる
    # 正直Payjpでの使いどころが分からない。
    render :action
  end

ぅう。。汚い・・・orz
できればエラーログなども吐かせると良い気がしますね。ログ設計の考え方はコチラが参考になりました。※Railsではありません^^;

まとめ

気合い入れて書き始めたのはいいけど、しりすぼみ感が半端ないですね、特に最後笑
まだまだ根本理解が足りていないですが、少しでも、今後の理解の助けになるといいなと思います。

課題というか疑問
・ これpayjpのWebAPIごとに全部定義するんだっけ・・・?
 え?嘘だよね?もしかして、何か根本から間違えている・・・??(ここまでやって今更笑
・ これのテストかぁ〜(遠い目
・ 例外処理というか、トランザクションの考慮も必要・・・
・ そもそもPayjpのWebApiって自分のプロジェクト上、どいうポジションに置いて管理すればいいのか

少し模索していきながら、答えに近づけたら、ここに整理しようと思います。。

参考

Railsアプリケーションにおけるエラー処理(例外設計)の考え方
【Rails】例外処理の書き方(begin, rescue, raise,retry, ensure)
RubyLearning:Rubyの例外
Rubyリファレンス:Exception
【Rails】独自例外の置き場所(配置ディレクトリ)について
そもそもRailsでカスタム例外を定義すべき機会は少ない
GitHub:Payjp
Payjp:APIリファレンス

15
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
12