---
title: "Ruby Faradayの使い方とタイムアウト設定｜既定値・例外の違い・faraday-retryの落とし穴"
url: "https://www.issoh.co.jp/tech/details/2879/"
published: 2024-07-02
updated: 2026-10-18
categories: ["Ruby"]
publisher: "株式会社一創"
---

# Ruby Faradayの使い方とタイムアウト設定｜既定値・例外の違い・faraday-retryの落とし穴

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](https://rubygems.org/gems/faraday) のAPIで確認した値です。本体の実行時依存は faraday-net\_http・json・logger の3つだけで、再試行やmultipartは使うときに個別に足します。faraday-net\_http の上限は 2.15.0 で「3.6未満」に広がり（[v2.15.0 のリリースノート](https://github.com/lostisland/faraday/releases/tag/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 のリリースノート](https://github.com/lostisland/faraday-net%5Fhttp/releases/tag/v3.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のセキュリティアドバイザリ](https://github.com/lostisland/faraday/security/advisories)（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の使い方](/tech/details/4221/)で整理しています。

2.0（2022-01-04）で、net\_http 以外のアダプタと retry・multipart ミドルウェアが本体から外れました。公式の [UPGRADING.md](https://github.com/lostisland/faraday/blob/main/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認証](/tech/details/2905/)にまとめています。外部APIとの連携部分の設計や開発を任せたい場合は、一想の[API開発・システム連携](/service/system/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?](https://stackoverflow.com/questions/10322283/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）](https://github.com/ruby/net-http/releases/tag/v0.5.0)で入った設定で、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 の実装](https://github.com/lostisland/faraday-retry/blob/v2.4.0/lib/faraday/retry/middleware.rb)に基づきます。この既定のまま、`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仕様から処理自体がべき等だと確認できる場合や、最初の要求が適用されていないと確認できる場合も対象になります。再試行の回数・待ち時間の伸ばし方・ジッターの決め方は[リトライパターンとは？指数バックオフ・ジッター・リトライ予算の実装判断を解説](/tech/details/16185/)で、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](https://github.com/lostisland/faraday/pull/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 など）へ移します。

## 関連記事

- [リトライパターンとは？指数バックオフ・ジッター・リトライ予算の実装判断を解説](/tech/details/16185/)
- [Rails APIとは？作成手順・JWT認証・React連携・Docker環境を実装解説【Rails 8.1】](/tech/details/2905/)
- [gemとBundlerの使い方｜Bundler 4系で変わったコマンドとGemfileの書き方](/tech/details/4221/)
- [Rails Active Jobとは？非同期ジョブ処理の実装とSolid Queue・リトライ設定](/tech/details/3988/)
- [Rubyの最新バージョンは4.0.7｜Ruby 3系との違い・サポート期限・更新手順（2026年9月）](/tech/details/10592/)

---

出典: [Ruby Faradayの使い方とタイムアウト設定｜既定値・例外の違い・faraday-retryの落とし穴](<https://www.issoh.co.jp/tech/details/2879/>)（株式会社一創）
