Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Rubyの例外処理をマスターする:ensure、retry、そして例外の再発生

発生した例外はrescueで捕捉し、問題が起きた際に別のコードパスを実行できますが、例外処理の方法はそれだけではありません。今回のAppSignal Academyでは、retryensureキーワードの使い方を解説し、さらに捕捉した例外を再発生させる(reraise)方法についても見ていきます。

ここでは、信頼性の低いWeb APIと通信する場面を想定してみましょう。そのAPIはたまにダウンするだけでなく、動作が非常に遅く、リクエストに数秒かかることもあります。私たちのライブラリはこのAPIに依存しているため、できる限り耐障害性を高める必要があります。

ensureで後処理を保証する

ensureキーワードは、例外が発生した場合でも必ず特定のコードブロックを実行することを保証するために使われます。

私たちのライブラリでは、例えばタイムアウトなどでリクエストが失敗した場合でも、Net::HTTP.startが開いたTCP接続を確実に閉じたいと考えています。そのためには、まずリクエスト全体をbegin/ensure/endブロックで囲みます。ensure部分のコードは、直前のbeginブロックで例外が発生しても必ず実行されます。

ensureブロック内では、http変数がnilでない場合にのみNet::HTTP#finishを呼び出してTCP接続を閉じるようにします。http変数がnilになるのは、TCP接続のオープン自体に失敗した場合です(この場合も例外が発生します)。

require "net/http"
 
begin
  puts "Opening TCP connection..."
  http = Net::HTTP.start(uri.host, uri.port)
  puts "Sending HTTP request..."
  puts http.request_get(uri.path).body
ensure
  if http
    puts "Closing the TCP connection..."
    http.finish
  end
end

: ここでTCP接続を手動で閉じているのは、後で再試行する際に接続を再利用できるようにするためです。ただし、Net::HTTP.startは接続を確実に閉じる処理を行うブロックを受け取れるため、上記のサンプルはensureを省いて書き直すことも可能です。興味深いことに、Net::HTTP自身も内部ではensureブロックによってこの仕組みを実現しています。

retryで処理を再試行する

retryキーワードを使うと、ブロック内のコードを再試行できます。rescueブロックと組み合わせれば、接続のオープンに失敗した場合や、APIの応答に時間がかかりすぎた場合に、処理をやり直すことができます。

そのために、Net::HTTP.startの呼び出しにread_timeoutを追加し、タイムアウトを10秒に設定します。それまでにリクエストへの応答が返ってこなければ、Net::ReadTimeoutが発生します。

また、APIが完全にダウンしてTCP接続を開けないケースに備えて、Errno::ECONNREFUSEDも捕捉します。この場合、http変数はnilになっています。

例外をrescueしてretryを呼び出すと、beginブロックが最初からやり直され、タイムアウトが発生しなくなるまで同じリクエストが繰り返されます。すでに接続が確立されている場合は、httpオブジェクトを再利用します。

require "net/http"
 
http = nil
uri = URI("https://localhost:4567/")
 
begin
  unless http
    puts "Opening TCP connection..."
    http = Net::HTTP.start(uri.host, uri.port, read_timeout: 10)
  end
  puts "Executing HTTP request..."
  puts http.request_get(uri.path).body
rescue Errno::ECONNREFUSED, Net::ReadTimeout => e
  puts "Timeout (#{e}), retrying in 1 second..."
  sleep(1)
  retry
ensure
  if http
    puts "Closing the TCP connection..."
    http.finish
  end
end

これで、Net::ReadTimeoutが発生しなくなるまで、1秒ごとにリクエストを再試行します。

$ ruby retry.rb
Opening TCP connection...
Executing HTTP request...
Timeout (Net::ReadTimeout), retrying in 1 second...
Executing HTTP request...
Timeout (Net::ReadTimeout), retrying in 1 second...
Executing HTTP request...
Timeout (Net::ReadTimeout), retrying in 1 second...
Executing HTTP request...
... (in an endless loop)

これによりタイムアウトで例外が発生することはなくなりますが、このように無制限にリトライを繰り返しても、APIの復旧にはむしろ悪影響です。問題なのは、APIが応答しないままの場合、このコードが永遠にループし続けることです。そこで、再試行の間隔を少しずつ空け、一定回数試したら諦めるように改良しましょう。

諦めどきを見極める:raiseによる例外の再発生

例外をrescueすると、発生した例外オブジェクトがrescueブロックに渡されます。これを利用すれば、ログへメッセージを出力するなど例外から情報を取り出せるだけでなく、スタックトレースを保ったまままったく同じ例外を再発生させることもできます。

begin
  raise "Exception!"
rescue RuntimeError => e
  puts "Exception happened: #{e}"
  raise e
end

rescueブロック内で例外オブジェクトにアクセスできるため、コンソールやエラーモニタリングツールにエラーを記録できます。実際、AppSignalのインテグレーションがエラーを追跡する仕組みも、まさにこの「rescueして再発生させる」という方式です。

: Rubyは最後に発生した例外を$!という変数に格納しており、raiseキーワードはデフォルトでこれを使用します。引数なしでraiseを呼び出すと、最後に発生した例外がそのまま再発生します。

私たちのライブラリでは、この再発生の仕組みを利用して、数回の再試行の後はAPIへの負荷を軽減できます。そのために、再試行の回数をretries変数で管理します。

タイムアウトが発生するたびにカウントを増やし、その値が3以下かどうかを確認します。最大3回まで再試行したいからです。3以下であればretryで再試行し、そうでなければraiseで最後の例外を再発生させます。

require "net/http"
 
http = nil
uri = URI("https://localhost:4567/")
retries = 0
 
begin
  unless http
    puts "Opening TCP connection..."
    http = Net::HTTP.start(uri.host, uri.port, read_timeout: 1)
  end
  puts "Executing HTTP request..."
  puts http.request_get(uri.path).body
rescue Errno::ECONNREFUSED, Net::ReadTimeout => e
  if (retries += 1) <= 3
    puts "Timeout (#{e}), retrying in #{retries} second(s)..."
    sleep(retries)
    retry
  else
    raise
  end
ensure
  if http
    puts 'Closing the TCP connection...'
    http.finish
  end
end

sleepの呼び出しにretries変数を使うことで、試行を重ねるごとに待ち時間を段階的に長くできます。

$ ruby reraise.rb
Opening TCP connection...
Executing HTTP request...
Timeout (Net::ReadTimeout), retrying in 1 second(s)...
Executing HTTP request...
Timeout (Net::ReadTimeout), retrying in 2 second(s)...
Executing HTTP request...
Timeout (Net::ReadTimeout), retrying in 3 second(s)...
Executing HTTP request...
Closing the TCP connection...
/lib/ruby/2.4.0/net/protocol.rb:176:in `rbuf_fill': Net::ReadTimeout (Net::ReadTimeout)
...
from reraise.rb:13:in `<main>'

このコードは3回リクエストを再試行した後、諦めて最後のエラーを再発生させます。その後、呼び出し元の一つ上のレベルでエラーを処理するか、APIの応答なしには処理を完了できないのであれば、アプリケーションをクラッシュさせるという判断もできます。

まとめ:耐障害性のあるWeb APIクライアント

これらの手法を組み合わせることで、わずか20行ほどのコードで耐障害性のあるWeb APIクライアントを実現できました。APIがダウンしていたり応答しなかったりした場合にはリクエストを自動的に再試行し、それでも復旧しない場合には適切に諦めることができます。

例外処理について新しい発見があったなら幸いです。この記事(またはAppSignal Academyシリーズの他の記事)へのご意見や、さらに詳しく学びたいRubyのテーマがあれば、ぜひお気軽にお知らせください。

  1. Rubyで例外にコンテキストデータを追加する3つの方法

    標準のバックトレースやエラーメッセージだけでは、エラーの原因を特定するのに十分な情報が得られないことがあります。そんなときに役立つのが、例外への追加データの付与です。幸い、Rubyではこれをとても簡単に実現できます。 エラーメッセージをカスタマイズする 例外にコンテキスト情報を加える最もシンプルな方法は、メッセージそのものに情報を組み込んでしまうことです。次の例では、発生した例外を捕捉し、新しいメッセージを付けて再送出しています。 begin raise foo rescue => e raise e.class, bar end # RuntimeError: bar

  2. Rubyでのログ出力をマスターする:LoggerとLogrageの使い方徹底解説

    Rubyでのログ出力入門:LoggerとLogrageの使い方 ロギングは、アプリケーション開発において最も重要なタスクの一つです。ログは以下のような場面で活用されます。 アプリ内部で何が起きているかを把握したいとき アプリケーションを監視したいとき 特定のデータに関するメトリクスを収集したいとき 新しいプログラミング言語を学ぶ際、最初に選ばれるのはその言語がネイティブに備えているロギング機構でしょう。標準機能は通常、扱いやすく、ドキュメントも充実しており、コミュニティでも広く使われています。 ただし、ログデータの内容や扱い方は、企業の方針、ビジネスの性質、アプリケーションの種類に