Faraday(ファラデー)は、RubyでHTTPリクエストを送るためのgemです。接続(Faraday::Connection)にミドルウェアを積み重ね、JSONの変換・認証ヘッダー・再試行・ログ出力を部品として差し込めます。本記事では、2026年10月時点の最新版 Faraday 2.15.0 と faraday-net_http 3.5.0 を Ruby 4.0.6 に入れ、応答をわざと遅らせるローカルサーバーを相手に、タイムアウトと例外と再試行の挙動を確かめた結果(検証日:2026年10月18日)をもとに使い方を整理します。2.14.4 から挙動が変わった箇所は、旧版でも同じ手順で動かして差を示しています。
まとめ:Faradayのタイムアウトと再試行の要点
- Faraday本体はタイムアウトの既定値を持たず(
nil)、既定の net_http アダプタでは Net::HTTP の既定値(接続・読み取り・書き込みの各60秒)がそのまま効きます。 timeout: 5は、個別指定の無い接続・読み取り・書き込みのタイムアウトをまとめて5秒にする指定で、net_http アダプタではリクエスト全体の上限になりません。0.5秒おきに1バイトずつ返すサーバーには、timeout: 1でも約6秒間待ち続けました。- 応答待ちの超過は
Faraday::TimeoutError、接続確立の超過はFaraday::ConnectionFailedになります。TimeoutErrorだけを捕まえると接続タイムアウトを取り逃します。faraday-net_http 3.5.0 からは、gzip で圧縮された本文を展開できないときもConnectionFailedになりました。 - faraday-retry の既定は「GET・PUT・DELETE・HEAD・OPTIONS をタイムアウト系の例外で最大2回まで再試行」です。接続失敗・POST・503応答は、設定を足さない限り再試行されません。
- 既定の
exceptionsのままだと、retry_statusesはraise_errorを retry より後ろ(内側)に書いたときに効きません。raise_errorを先に書くのが確実です。 - 2.15.0 では、一度リクエストを送った接続のアダプタを差し替えると
Faraday::RackBuilder::StackLockedになります。2.14.4 では例外になりませんでした。 - 2.14.2 以前には深刻度 high の脆弱性(CVE-2026-54297)があります。2.x系は 2.14.3 以上へ上げてください。
Faradayとは:アダプタとミドルウェアで組むRubyのHTTPクライアント
Faradayは、実際の通信を行う「アダプタ」と、リクエストとレスポンスを加工する「ミドルウェア」を分けた設計のHTTPクライアントです。既定のアダプタは標準ライブラリの Net::HTTP を使う net_http で、Faraday.default_adapter は :net_http を返します。Octokit(GitHub APIのRubyクライアント)をはじめ多くのAPIラッパーgemが内部で使っており、rubygems.org での累計ダウンロード数は12億を超えます。
2026年10月時点の版とgem構成
| gem | 最新版 | 公開日 | 役割 |
|---|---|---|---|
| faraday | 2.15.0 | 2026-10-16 | 本体(Ruby 3.0以上) |
| faraday-net_http | 3.5.0 | 2026-10-11 | 既定アダプタ(本体の依存で自動導入) |
| faraday-retry | 2.4.0 | 2025-12-25 | 再試行ミドルウェア |
| faraday-net_http_persistent | 2.3.1 | 2025-06-17 | 接続を再利用するアダプタ |
| faraday-multipart | 1.2.0 | 2025-12-25 | ファイル送信(multipart) |
| faraday-follow_redirects | 0.5.0 | 2025-12-27 | リダイレクト追従 |
版と公開日は rubygems.org のAPIで確認した値です。本体の実行時依存は faraday-net_http・json・logger の3つだけで、再試行やmultipartは使うときに個別に足します。faraday-net_http の上限は 2.15.0 で「3.6未満」に広がり(v2.15.0 のリリースノート)、faraday-net_http はさらに net-http gem(0.5以上1.0未満)に依存するため、Gemfile.lock で版を固定していない状態で bundle install すると、Ruby 同梱版(Ruby 4.0.6 では 0.9.1)とは別に、その時点の最新の net-http(2026-10-18 時点で 0.10.0)が入ります。1.x系にも、2026-06-24 に 1.10.6 が脆弱性修正のバックポートとして出ています。
2.15.0 と faraday-net_http 3.5.0 にはプロキシの no_proxy 照合や接続の複製に関する修正も入っていますが、本記事では例外処理とアダプタ差し替えに関わる2つを取り上げます。gzip・deflate の本文を展開できないときの例外が Zlib::DataError などから Faraday::ConnectionFailed に変わったこと(3.5.0 のリリースノートは「破壊的変更ではなくバグ修正」と位置づけています)と、リクエスト後のアダプタ変更が例外になったことです。前者は例外の章、後者はアダプタの章で実測と一緒に説明します。3.5.0 では socksify gem(1.8系)を入れた場合に限り、socks:// で始まるプロキシURLも使えるようになりました。
2.x系で修正された脆弱性と上げるべき版
| CVE | 深刻度 | 影響する2.x | 修正版 | 内容 |
|---|---|---|---|---|
| CVE-2026-54297 | high | 2.0.0〜2.14.2 | 2.14.3 | 深いネストのクエリでスタック枯渇 |
| CVE-2026-25765 | medium | 2.0.0〜2.14.0 | 2.14.1 | プロトコル相対URLでホストを上書き(SSRF) |
| CVE-2026-33637 | low | 2.0.0〜2.14.1 | 2.14.2 | 上記の修正漏れ(URIオブジェクト経由) |
いずれも Faradayのセキュリティアドバイザリ(GitHub Advisory Database)の登録内容です。CVE-2026-54297 は1.x系でも 1.10.5 以前が対象で、1.10.6 で直っています。Gemfile.lock の faraday が 2.14.3 未満なら、まず bundle update faraday を当ててください。
インストールとfaraday_middlewareからの移行
# Gemfile
gem 'faraday', '~> 2.14', '>= 2.14.3'
gem 'faraday-retry' # 再試行を使う場合
gem 'faraday-net_http_persistent' # 接続を再利用する場合
bundle install 後、コード側では require 'faraday' に加えて require 'faraday/retry' のように使う部品を読み込みます。Bundlerの書き方はgemとBundlerの使い方で整理しています。
2.0(2022-01-04)で、net_http 以外のアダプタと retry・multipart ミドルウェアが本体から外れました。公式の UPGRADING.md によると、net_http アダプタも 2.0 で一度外れ、2.0.1 で戻っています。旧来の faraday_middleware(最終版 1.2.1)は README に「Faraday 2.0 に対応する更新はしない」と明記された非推奨gemです。JSONの変換(request :json と response :json)は本体に入ったので、JSONのためだけに faraday_middleware を入れていたなら外せます。
Connectionの作り方とGET・POSTの基本
Faradayでは、ベースURL・既定ヘッダー・タイムアウト・ミドルウェアを Faraday.new にまとめ、その接続からリクエストを送ります。次のコードは Faraday 2.15.0 で実行し、コメントの出力を確認したものです(URLだけ手元のテストサーバーに差し替えています)。
require 'faraday'
conn = Faraday.new(url: 'https://api.example.com',
headers: { 'User-Agent' => 'my-app/1.0' },
request: { open_timeout: 2, read_timeout: 5, write_timeout: 5 }) do |f|
f.request :authorization, 'Bearer', -> { ENV.fetch('API_TOKEN') }
f.request :json # Hashのbodyを JSON 文字列にする
f.response :json, parser_options: { symbolize_names: true }
f.response :raise_error # 4xx・5xxを例外にする
end
res = conn.get('/v1/users', { page: 2, per_page: 50 })
res.status # => 200
res.success? # => true(2xxならtrue)
res.headers['x-request-id'] # ヘッダー名の大文字小文字は区別しない
res.body # => パース済みのHash(symbolize_names でキーはSymbol)
res = conn.post('/v1/users', { name: 'taro' })
# 送信ヘッダー Content-Type: application/json、本文 {"name":"taro"}
res = conn.get('/v1/report') { |req| req.options.read_timeout = 30 } # 1回の読み取り待ちをこのリクエストだけ30秒に
GETの第2引数のHashはクエリ文字列になり、request :json を積むとPOSTのHashがJSONに変換されて Content-Type: application/json が付きます。request :authorization にラムダを渡すと、リクエストのたびにトークンを取り直します。レスポンスは status・headers・body で読み、headers はサーバーが X-Request-Id と返しても 'x-request-id' で引けます。User-Agent を指定しないと Faraday v2.15.0 のように版番号入りで送られるので、相手のAPIに利用者を伝えたい場合は上のように headers で自分のアプリ名に置き換えます。
Railsでの置き場所:接続を1つ持つサービスクラス
接続をリクエストのたびに作り直すと、ミドルウェアの組み立てを毎回やり直すことになります。Railsでは外部APIごとにクライアントクラスを作り、接続をクラス側で1つだけ持つ形が扱いやすくなります。
# app/clients/example_api_client.rb
require 'faraday'
require 'faraday/retry'
class ExampleApiClient
def self.connection
@connection ||= Faraday.new(url: ENV.fetch('EXAMPLE_API_URL'),
request: { open_timeout: 2, timeout: 10 }) do |f|
f.response :raise_error
f.request :retry, max: 2, interval: 0.5, backoff_factor: 2,
retry_statuses: [429, 503],
exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [Faraday::ConnectionFailed]
f.request :json
f.response :json
end
end
def fetch_user(id)
self.class.connection.get("/v1/users/#{id}").body
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
raise "upstream unavailable: #{e.wrapped_exception.class}"
end
end
このクラスはURLだけ手元のテストサーバーに差し替えて動かし、2回目以降の connection が同じオブジェクトを返すことを確認しました。raise_error を retry より先に書いている理由と、exceptions に ConnectionFailed を足している理由は、後の再試行の章で実測値と一緒に説明します。RailsでAPIサーバー側を作る手順はRails APIの作成手順とJWT認証にまとめています。外部APIとの連携部分の設計や開発を任せたい場合は、一想のAPI開発・システム連携でも対応しています。
タイムアウト設定:4つのオプションとNet::HTTPへの渡り方
| オプション | 待つ対象 | net_httpでの設定先 |
|---|---|---|
timeout |
下の3つの一括指定 | 個別指定が無いものに入る |
open_timeout |
TCP接続とTLSハンドシェイク | Net::HTTP#open_timeout |
read_timeout |
1回の読み取り | Net::HTTP#read_timeout |
write_timeout |
1回の書き込み | Net::HTTP#write_timeout |
Faradayのアダプタ基底クラスは、読み取り・書き込み・接続の各タイムアウトを options[:read_timeout] || options[:timeout] の形で取り出します。個別の値が優先され、無ければ timeout が入る仕組みです。Net::HTTP は接続時の ssl_socket_connect にも open_timeout を渡すので、TLSハンドシェイクの待ちも open_timeout に含まれます。open_timeout を書かずに timeout: 1 だけを渡して応答の返らないアドレス(10.255.255.1)へ接続すると、約1秒で Faraday::ConnectionFailed(元の例外は Net::OpenTimeout)になりました。timeout だけでも接続待ちは切れるということです。Stack Overflow で上位に出る「What is timeout and open timeout in Faraday?」の回答(2014年)は当時のアダプタのコードを引いていますが、現在は書き込み(write_timeout)にも同じ規則で値が入ります。
# 接続全体の既定値として渡す
conn = Faraday.new(url: 'https://api.example.com',
request: { open_timeout: 2, timeout: 10 })
# ブロックの中で設定する書き方もある
conn = Faraday.new(url: 'https://api.example.com') do |f|
f.options.open_timeout = 2
f.options.timeout = 10
end
# 1リクエストだけ上書きする
conn.get('/v1/export') { |req| req.options.read_timeout = 60 }
タイムアウトの既定値:Faraday側のnilとNet::HTTPの60秒
Faraday::RequestOptions.new.timeout は nil です。何も指定しないと net_http アダプタは Net::HTTP の値に触らないため、Net::HTTP の既定値である open_timeout・read_timeout・write_timeout の各60秒が使われます(net-http 0.9.1・0.10.0 とも同じ)。「Faradayは既定でタイムアウトが無く、無限に待つ」という説明を見かけますが、net_http アダプタでは当てはまりません。ただし接続に59秒、応答待ちに59秒かかっても例外にならないので、既定値のまま本番に出すのは長すぎます。
net_httpアダプタのtimeoutと総時間の上限の違い
read_timeout は「1回の読み取りを何秒待つか」なので、少しずつでもデータが届き続ける限り発火しません。ヘッダーを返したあと本文を0.5秒おきに1バイトずつ10回送るサーバーに timeout: 1 で接続すると、例外にならず5.94秒後にステータス200で完了しました(2.14.4 では6.03秒)。全体を1秒で止めたつもりの設定でも、この相手には完了まで約6秒かかりました。Faraday本体の options/env.rb のコメントは :timeout を「time limit for the entire request」と説明していますが、net_http アダプタはこの値を Net::HTTP の読み取り・書き込み・接続の各タイムアウトへ配るだけなので、コメントどおりの総時間の上限にはなりません。
1回の通信を総時間で打ち切るには、転送全体のタイムアウトを持つアダプタが候補になります。ただし、再試行とその待機時間まで含む処理全体の期限は、呼び出し側で別途管理する必要があります。faraday-typhoeus 2.0.0 の実装は、Faradayの timeout を libcurl の timeout_ms(転送全体の上限)へ、open_timeout を connecttimeout_ms へ渡しています。
既定のタイムアウトをまとめて変える2つの方法
接続を作るたびに request: { timeout: ... } を書き忘れると、その接続だけ60秒の既定に戻ります。既定値そのものを変える方法は2つあり、効く範囲と、設定前に作った接続へ効くかどうかが違います。
# 1. Faradayの接続だけに効く(Faraday.new と Faraday.get などのモジュール呼び出し)
Faraday.default_connection_options = { request: { open_timeout: 2, timeout: 10 } }
# 2. プロセス内の Net::HTTP すべてに効く(net-http 0.5.0 以降)
Net::HTTP.default_configuration = { open_timeout: 2, read_timeout: 10, write_timeout: 10 }
1 は timeout: 1 を入れたあと、オプションを何も渡さない Faraday.new と Faraday.get のどちらも、3秒待つ相手に約1秒で TimeoutError になりました。ただし設定より前に作った接続は元の値のままで、同じ相手に3秒待って200を受け取りました。1 は接続を作る時点で値を写し取るためです。2 は net-http 0.5.0(2024-11-07)で入った設定で、Ruby 3.4 以降の同梱版と、faraday-net_http が引き込む net-http 0.10.0 で使えます。Faraday側の値が nil のときだけ効き、read_timeout: 1 を入れた状態でも接続に timeout: 5 を渡すと Faraday 側が勝って3秒待って200が返りました。net_http アダプタはリクエストのたびに Net::HTTP を作るので、2 は設定前に作った Faraday の接続にも効きました。そのぶん Faraday 以外の gem が使う Net::HTTP にも及ぶので、Railsでは接続を作る前に読み込まれる初期化ファイル(config/initializers/)で 1 を設定するのが安全です。
タイムアウト値の決め方と処理全体の期限
タイムアウトは再試行の回数だけ積み上がります。timeout: 1・max: 2・interval: 0.5・backoff_factor: 2 の設定で応答しないサーバーを呼ぶと、1秒の待ちが3回と、0.5秒・1.0秒の待機が挟まり、例外が返るまで4.8秒かかりました。「(接続+読み取り)×(max+1)+待機の合計」は、応答が止まったときに1回あたりどれだけ待つかの目安にはなります。ただし前の節のように少しずつ届く応答では読み取りが何度も繰り返されるため、この式は処理全体の上限を保証しません。呼び出し元(Webリクエストやジョブ)に期限があるなら、読み取り単位のタイムアウトとは別に、総時間を持つアダプタや呼び出し側の期限管理で全体を区切ります。
決め方の目安として、open_timeout は2〜5秒の短めに置きます。ただし、この秒数は一律の推奨値ではなく、接続時間の実績と呼び出し元の期限から調整する初期設定の例です。read_timeout は相手APIの処理時間の実績に合わせ、重いエンドポイントだけリクエスト単位で延ばします。
例外の違い:TimeoutError・ConnectionFailed・ServerErrorの使い分け
| 起きたこと | Faradayの例外 | 元の例外 | 実測 |
|---|---|---|---|
| 応答待ちが read_timeout 超過 | TimeoutError | Net::ReadTimeout | 1.00秒 |
| 接続確立が open_timeout 超過 | ConnectionFailed | Net::OpenTimeout | 1.00秒 |
| 接続拒否(ポートが閉じている) | ConnectionFailed | Errno::ECONNREFUSED | 0.00秒 |
| TLSのエラー | SSLError | OpenSSL::SSL::SSLError | ソースで確認 |
| gzip本文を展開できない(3.5.0〜) | ConnectionFailed | Zlib::DataError | 0.00秒 |
| 同上(3.4.4まで) | 変換されない | Zlib::DataError | 0.00秒 |
| 500応答(raise_errorなし) | 例外なし | なし | status 500 |
| 500応答(raise_errorあり) | ServerError | なし | 0.00秒 |
| 429応答(raise_errorあり) | TooManyRequestsError | なし | 0.02秒 |
各タイムアウトは1秒に設定し、接続タイムアウトは応答の返らないアドレス(10.255.255.1 と 192.0.2.1)で起こしました。表の例外名はすべて Faraday:: 名前空間のクラスで、元の例外は e.wrapped_exception で取り出せます。
注意したいのは2行目です。faraday-net_http 3.5.0 は、接続系のエラーをまとめて ConnectionFailed に変換する一覧に Net::OpenTimeout を入れています。この変換が TimeoutError への変換より先に働くため、接続タイムアウトは「タイムアウト」ではなく「接続失敗」として届きます。rescue Faraday::TimeoutError だけを書いたコードは、相手のサーバーが落ちているときの接続タイムアウトを捕まえられません。
gzip の行は、Content-Encoding: gzip を付けながら圧縮されていない本文を返すテストサーバーで起こしました。3.4.4 では Zlib::DataError が Faraday の例外に包まれずにそのまま上がってきたので、rescue Faraday::Error だけで外部APIのエラーを受けていたコードは取りこぼしていました。3.5.0 では ConnectionFailed として届き、e.wrapped_exception で元の Zlib::DataError を確認できます。反対に、rescue Zlib::Error を書いて個別に扱っていたコードは、3.5.0 に上げると到達しなくなります。
継承関係とrescueの書き方
Faraday::TimeoutError は Faraday::ServerError のサブクラスで、その親が Faraday::Error です。ConnectionFailed と SSLError は ServerError を経由せず、直接 Faraday::Error を継承しています。そのため rescue Faraday::ServerError は5xx応答に加えて読み取りタイムアウトも捕まえ、接続失敗は捕まえません。
begin
res = conn.get('/v1/orders')
rescue Faraday::ConnectionFailed, Faraday::TimeoutError => e
# 相手に届かない・応答が遅い:一時障害として扱う
logger.warn("upstream down: #{e.wrapped_exception.class}")
rescue Faraday::TooManyRequestsError => e
retry_after = e.response_headers&.fetch('retry-after', nil)
rescue Faraday::ClientError => e
# 4xx:多くはリクエスト側の誤り。408・409などの再試行可否はAPI仕様で判断
logger.error("#{e.response_status} #{e.response_body}")
rescue Faraday::ServerError => e
# 5xx
end
rescue は上から順に評価されるので、サブクラスを先に並べます。TooManyRequestsError は ClientError のサブクラスなので、ClientError より上に置かないと到達しません。
raise_errorが投げるステータス別の例外
| ステータス | 例外クラス | 親クラス |
|---|---|---|
| 400 | BadRequestError | ClientError |
| 401 | UnauthorizedError | ClientError |
| 403 | ForbiddenError | ClientError |
| 404 | ResourceNotFound | ClientError |
| 407 | ProxyAuthError | ClientError |
| 408 | RequestTimeoutError | ClientError |
| 409 | ConflictError | ClientError |
| 422 | UnprocessableContentError | ClientError |
| 429 | TooManyRequestsError | ClientError |
| その他の4xx | ClientError | Error |
| 5xx | ServerError | Error |
| ステータスなし | NilStatusError | ServerError |
408応答の RequestTimeoutError は、サーバーが「リクエストの受信を待ちきれなかった」と返したときの例外で、クライアント側のタイムアウトである TimeoutError とは別物です。名前が似ていますが親クラスも違います。422は旧名の UnprocessableEntityError も別名として残っています。特定のステータスを例外にしたくない場合は f.response :raise_error, allowed_statuses: [404] のように除外できます。
faraday-retryの既定値と再試行されない3つのケース
再試行ミドルウェアは 2.0 で faraday-retry gem に分かれました。gemを入れずに f.request :retry と書くと、接続を組み立てる時点で Faraday::Error: :retry is not registered on Faraday::Request が出ます。また「To use retry middleware with Faraday v2.0+, install faraday-retry gem」という英語の警告は Faraday 本体ではなく Octokit が出しているもので、lib/octokit/default.rb が faraday-retry を読み込めなかったときに表示します。Gemfile に gem 'faraday-retry' を足せば消えます。
| オプション | 既定値 |
|---|---|
max |
2(初回と合わせて最大3回送る) |
interval |
0(待たずに再送) |
backoff_factor |
1(待ち時間を伸ばさない) |
interval_randomness |
0 |
max_interval |
Float::MAX |
methods |
delete・get・head・options・put |
exceptions |
ETIMEDOUT・Timeout::Error・TimeoutError・RetriableResponse |
retry_statuses |
空(ステータスでは再試行しない) |
既定値は faraday-retry 2.4.0 の実装に基づきます。この既定のまま、raise_error を retry の後ろに積んだ接続で条件を変え、テストサーバーへの到達回数を数えた結果が次の表です。
| 条件 | サーバー到達 | 結果 |
|---|---|---|
| GET・読み取りタイムアウト | 3回 | TimeoutError(3.30秒) |
| POST・読み取りタイムアウト | 1回 | TimeoutError(1.00秒) |
| GET・接続拒否 | 再試行0回 | ConnectionFailed |
| GET・接続タイムアウト | 再試行0回 | ConnectionFailed |
| GET・503応答(raise_errorあり) | 1回 | ServerError |
再試行されないのは、接続できなかったとき・POSTのとき・エラーステータスが返ったときの3つです。以下、それぞれの直し方を説明します。
接続失敗を再試行する設定
接続拒否と接続タイムアウトはどちらも ConnectionFailed で届き、これは既定の exceptions に入っていません。既定の一覧を残したまま足すには、次のように書きます。
f.request :retry,
exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [Faraday::ConnectionFailed]
この設定で接続拒否を起こすと、retry_block が2回呼ばれ、再試行が働いたことを確認できました。ロードバランサーの切り替えやデプロイ直後の一瞬の接続拒否を吸収したい場合に使います。
faraday-net_http 3.5.0 以降では、この設定で gzip の展開失敗も再試行の対象に入ります。壊れた圧縮本文を返すサーバーに GET すると、サーバーへの到達は3回になりました。同じ応答が返り続ける障害なら再試行は無駄打ちです。除外したい場合、retry_if だけを書いても効きません。faraday-retry 2.4.0 は「methods に含まれるメソッドなら無条件に再試行し、含まれないときだけ retry_if を呼ぶ」実装なので、methods: [] と組み合わせて判定を retry_if に寄せます。
skip_broken_body = lambda do |env, e|
idempotent = %i[delete get head options put].include?(env[:method])
broken_body = e.respond_to?(:wrapped_exception) && e.wrapped_exception.is_a?(Zlib::Error)
idempotent && !broken_body
end
f.request :retry, methods: [], retry_if: skip_broken_body,
exceptions: Faraday::Retry::Middleware::DEFAULT_EXCEPTIONS + [Faraday::ConnectionFailed]
この設定(raise_error を先に書き、retry_statuses: [503] も付けた接続)では、壊れたgzipのGETは1回で止まり、GETの読み取りタイムアウトと503は3回、POSTの読み取りタイムアウトは1回でした。
retry_statusesを効かせるミドルウェアの順序(raise_errorを先に登録)
503や429で再試行するには retry_statuses を指定しますが、ミドルウェアの順番によっては指定しても1回で終わります。
| 書き方 | 503への到達 |
|---|---|
| retry(retry_statuses: [503])だけ | 3回 |
| retry を書いたあとに raise_error | 1回 |
| raise_error を書いたあとに retry | 3回 |
| retry のあとに raise_error+exceptions に ServerError | 3回 |
Faradayのミドルウェアは書いた順に外側から並び、レスポンスは内側から外側へ戻ります。retry の後に raise_error を書くと、raise_error が内側で先に503を Faraday::ServerError に変えて投げます。retry が見るのは例外だけになり、ServerError は既定の exceptions に無いので再試行されません。faraday-retry の README は retry_statuses の使い方を載せていますが、この順番の制約には触れていません。raise_error を先に書けば、retry が503を受け取って再試行し、使い切った後に raise_error が例外にします。
conn = Faraday.new(url: 'https://api.example.com') do |f|
f.response :raise_error # 外側:再試行を使い切った後に例外化
f.request :retry, retry_statuses: [429, 503] # 内側:ステータスを見て再送
end
429応答の Retry-After ヘッダーも読みます。Retry-After: 1 を返すサーバーで試すと、1秒ずつ2回待って3回送り、2.01秒で TooManyRequestsError になりました。Retry-After の秒数が max_interval を超える場合は、待たずに再試行をあきらめます。
POSTを再試行に含める条件
methods: %i[get post] を渡すとPOSTも再試行され、読み取りタイムアウトでサーバーに3回届きました。タイムアウトは「サーバーが処理しなかった」ことを意味しません。応答が遅れただけでサーバー側では注文が作られていれば、再送で同じ注文が3件できます。POSTを再試行に入れてよいのは、Idempotency-Key ヘッダーによる重複排除などで再送時の副作用を防げる場合です。読み取り専用のPOSTなど、API仕様から処理自体がべき等だと確認できる場合や、最初の要求が適用されていないと確認できる場合も対象になります。再試行の回数・待ち時間の伸ばし方・ジッターの決め方はリトライパターンとは?指数バックオフ・ジッター・リトライ予算の実装判断を解説で、HTTPクライアントに限らず整理しています。
アダプタとミドルウェアの構成
net_httpアダプタによるNet::HTTP自動再送の無効化
Net::HTTP は max_retries の既定が1で(net-http 0.10.0 でも同じ)、べき等なメソッドなら読み取りタイムアウトや接続リセットのあと自動で1回送り直します。faraday-net_http はリクエスト設定の中で http.max_retries = 0 を入れており、GETの読み取りタイムアウトでもサーバーへの到達は1回でした。再試行の判断は Faraday 側の faraday-retry に一本化されているので、Net::HTTP の再送と二重になる心配はありません。
faraday-net_http_persistentによる接続の再利用
同じホストへ短い間隔で何度もリクエストするなら、TCPとTLSの接続を使い回すアダプタに替えると接続確立の時間を省けます。
require 'faraday'
require 'faraday/net_http_persistent'
conn = Faraday.new(url: 'https://api.example.com', request: { timeout: 5 }) do |f|
f.response :json
f.adapter :net_http_persistent, pool_size: 5 do |http|
http.idle_timeout = 30
end
end
faraday-net_http_persistent 2.3.1 は net-http-persistent(4.0.4以上5未満)に依存し、pool_size で接続プールの大きさを指定します。Faraday 2 では f.adapter がミドルウェアの一覧とは別に保持され、ブロックのどこに書いても最も内側で動きます(raise_error より前に書いても、builder.handlers の並びは変わりませんでした)。
2.15.0でのアダプタ差し替えの禁止とtestアダプタでの試験
Faraday は最初のリクエストを送った時点でミドルウェアの並びを固定します。f.use や f.request で後から足すと以前から Faraday::RackBuilder::StackLocked(can't modify middleware stack after making a request)でしたが、アダプタの差し替えだけはすり抜けていました。2.15.0 で PR #1701 が入り、conn.adapter :test のような差し替えも同じ例外になります。同じコードを 2.14.4 で動かすと例外になりませんでした。テストで本番用の接続を使い回し、途中で conn.adapter を書き換えて差し替えていた場合は、2.15.0 に上げると落ちます。
テスト用の接続は、最初から :test アダプタで組み立てます。スタブのブロックで例外を投げれば、タイムアウトや接続失敗のときの分岐と再試行を、ネットワークに出ずに確かめられます。
stubs = Faraday::Adapter::Test::Stubs.new
stubs.get('/v1/users/1') { raise Faraday::TimeoutError, 'timeout' }
calls = 0
conn = Faraday.new do |f|
f.request :retry, max: 2, retry_block: ->(**) { calls += 1 }
f.adapter :test, stubs
end
conn.get('/v1/users/1') # => Faraday::TimeoutError(calls は 2)
この例では retry の既定の exceptions に TimeoutError が入っているので、retry_block が2回呼ばれてから例外が上がりました。スタブで Faraday::ConnectionFailed を投げれば、接続失敗の分岐も同じように通せます。
ミドルウェアの自作とロガーのヘッダー漏れ
自作ミドルウェアは Faraday::Middleware を継承し、on_request(送信前)・on_complete(応答後)・on_error(例外時)のうち必要なものを定義します。基底クラスの call が、定義されているメソッドだけを呼び出します。
class TimingLogger < Faraday::Middleware
def on_request(env)
env[:started_at] = Process.clock_gettime(Process::CLOCK_MONOTONIC)
end
def on_complete(env)
ms = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - env[:started_at]) * 1000).round
Rails.logger.info("#{env.method.upcase} #{env.url.path} -> #{env.status} (#{ms}ms)")
end
end
conn = Faraday.new(url: 'https://api.example.com') do |f|
f.use TimingLogger
end
標準の f.response :logger は既定で headers: true・bodies: false です。認証ヘッダーを付けた接続にそのまま積むと、Authorization: "Bearer secret-token-123" のようにトークンが平文でログに残りました。ロガーを使うなら filter で伏せ字にします。
f.response :logger, Rails.logger, bodies: false do |logger|
logger.filter(/(Bearer )[^"]+/, '\1[FILTERED]')
end
# ログ出力: Authorization: "Bearer [FILTERED]"
よくある質問
Faradayのデフォルトのタイムアウトは何秒ですか?
Faraday本体の既定値は nil で、既定の net_http アダプタでは Net::HTTP の既定値である接続・読み取り・書き込みの各60秒が効きます。本番では open_timeout を2〜5秒、timeout または read_timeout を相手APIに合わせて明示してください。
timeoutとopen_timeoutの違いは何ですか?
open_timeout はTCP接続とTLSハンドシェイクを待つ時間です。timeout は接続・読み取り・書き込みの3つを一括で指定するもので、個別に指定した値のほうが優先されます。timeout: 1 だけでも接続待ちは1秒で切れ、そのときの例外は ConnectionFailed です。既定の net_http アダプタでは、どちらもリクエスト全体の上限にはなりません(faraday-typhoeus のように timeout を転送全体の上限として扱うアダプタもあります)。
Faraday::ConnectionFailedが出る原因は何ですか?
接続拒否・名前解決の失敗・到達不能のほか、net_http アダプタでは open_timeout の超過も ConnectionFailed になります。faraday-net_http 3.5.0 以降は、gzip・deflate の本文を展開できないときも同じ例外です。e.wrapped_exception が Net::OpenTimeout なら接続タイムアウト、Errno::ECONNREFUSED なら相手のポートが閉じています。
「To use retry middleware with Faraday v2.0+, install faraday-retry gem」と出るのはなぜですか?
Octokit が、Faraday 2 系の環境で faraday-retry を読み込めなかったときに出す警告です。Gemfile に gem 'faraday-retry' を追加して bundle install すると表示されなくなります。
faraday_middlewareはFaraday 2で使えますか?
使えません。faraday_middleware は Faraday 2.0 に対応しないことを README で明言した非推奨gemです。JSONの変換は Faraday 本体の request :json・response :json に置き換え、その他のミドルウェアは個別gem(faraday-retry・faraday-follow_redirects など)へ移します。