Railsでエラーに応じた遅延を使ったスマートなリトライ戦略を構築する

最近のRailsの変更により、ジョブのリトライロジックで実際に発生したエラーを検査できるようになりました。これにより、以前は実装が難しかったリトライ戦略が可能になります。

従来の方法

この変更以前は、retry_onのwait procは実行回数のみを受け取っていました:

class ApiJob < ApplicationJob
  retry_on ApiError, wait: ->(executions) { executions ** 2 }

  def perform(endpoint)
    ExternalApi.call(endpoint)
  end
end

これは基本的な指数バックオフには機能しますが、APIがいつリトライすべきかを正確に教えてくれる場合はどうでしょうか?レート制限のあるAPIは、しばしばRetry-Afterヘッダーを含みます。実行回数だけでは、その情報にアクセスできません。

新しい方法

PR #56601がRails mainにマージされ、エラーをオプションの第2引数として追加しました。これはRails 8.2で利用可能になります。

class ApiJob < ApplicationJob
  retry_on ApiError, wait: ->(executions, error) { error.retry_after || executions ** 2 }

  def perform(endpoint)
    ExternalApi.call(endpoint)
  end
end

これでエラーを検査してスマートな判断ができます。この変更は後方互換性があり、アリティ1のprocは引き続き実行回数のみを受け取ります。

パターン

実践での使用方法をいくつか紹介します。

パターン1:レート制限を尊重する

APIがレート制限をかけてきた場合、通常いつリトライすべきかを教えてくれます:

class RateLimitError < StandardError
  attr_reader :retry_after

  def initialize(message, retry_after: nil)
    super(message)
    @retry_after = retry_after
  end
end

class SyncToStripeJob < ApplicationJob
  retry_on RateLimitError,
    wait: ->(executions, error) {
      # APIのガイダンスを信頼し、適切なフォールバックを用意
      error.retry_after || (executions * 30.seconds)
    },
    attempts: 10

  def perform(user)
    response = Stripe::Customer.update(user.stripe_id, user.stripe_attributes)
  rescue Stripe::RateLimitError => e
    raise RateLimitError.new(e.message, retry_after: e.http_headers["retry-after"]&.to_i)
  end
end

これはAPIのバックプレッシャー信号を尊重し、盲目的に叩き続けることを避けます。

パターン2:例外メッセージからリトライヒントを抽出する

一部の例外はメッセージに有用な情報をエンコードしています。例えば、ロックタイムアウトはどのリソースが競合していたかを教えてくれるかもしれません:

class LockTimeoutError < StandardError
  attr_reader :lock_wait_time

  def initialize(message, lock_wait_time: nil)
    super(message)
    @lock_wait_time = lock_wait_time
  end
end

class ImportJob < ApplicationJob
  retry_on LockTimeoutError,
    wait: ->(executions, error) {
      # ロックを待った時間がわかれば、リトライ前に少なくともその時間待ち、
      # さらにジッターを追加
      base_delay = error.lock_wait_time || executions ** 2
      jitter = rand(0.0..1.0) * base_delay
      base_delay + jitter
    },
    attempts: 5

  def perform(batch)
    Record.transaction do
      batch.each { |row| Record.upsert(row) }
    end
  rescue ActiveRecord::LockWaitTimeout => e
    # データベースアダプターが提供していれば待機時間を抽出
    raise LockTimeoutError.new(e.message, lock_wait_time: extract_wait_time(e))
  end

  private

  def extract_wait_time(error)
    # エラーメッセージやメタデータから解析(利用可能な場合)
    error.message[/waited (\d+)s/, 1]&.to_i
  end
end

リトライ遅延が実際に観測された競合に適応するようになりました。

パターン3:エラー詳細に基づくコンテキスト対応の遅延

一部のエラーはリトライのタイミングに影響を与えるべきコンテキストを持っています:

class WebhookDeliveryError < StandardError
  attr_reader :status_code, :response_body

  def initialize(message, status_code:, response_body: nil)
    super(message)
    @status_code = status_code
    @response_body = response_body
  end

  def transient?
    status_code.in?(500..599) || status_code == 429
  end

  def suggested_delay
    case status_code
    when 429 then 60.seconds  # レート制限、大きく後退
    when 503 then 30.seconds  # サービス利用不可、中程度のバックオフ
    when 500..502, 504..599 then 10.seconds  # サーバーエラー、短い遅延
    else 5.seconds
    end
  end
end

class DeliverWebhookJob < ApplicationJob
  retry_on WebhookDeliveryError,
    wait: ->(executions, error) {
      error.suggested_delay * executions
    },
    attempts: 8

  def perform(webhook)
    response = HTTP.post(webhook.url, json: webhook.payload)

    unless response.status.success?
      raise WebhookDeliveryError.new(
        "Webhook delivery failed",
        status_code: response.status,
        response_body: response.body.to_s
      )
    end
  end
end

これは503を500とは異なる方法で処理し、両方を429とは異なる方法で処理します。

パターン4:共有ロジックによるマルチエラー戦略

複数の方法で失敗する可能性のあるジョブには、リトライロジックを集中化します:

module RetryStrategies
  STRATEGIES = {
    rate_limit: ->(executions, error) {
      error.respond_to?(:retry_after) ? error.retry_after : 60.seconds
    },
    transient: ->(executions, error) {
      (2 ** executions) + rand(0..executions)
    },
    network: ->(executions, error) {
      [5.seconds * executions, 2.minutes].min
    }
  }

  def self.for(type)
    STRATEGIES.fetch(type)
  end
end

class ExternalSyncJob < ApplicationJob
  retry_on RateLimitError, wait: RetryStrategies.for(:rate_limit), attempts: 10
  retry_on Net::OpenTimeout, wait: RetryStrategies.for(:network), attempts: 5
  retry_on Faraday::ServerError, wait: RetryStrategies.for(:transient), attempts: 5

  def perform(record)
    ExternalService.sync(record)
  end
end

これによりアプリケーション全体でリトライポリシーの一貫性が保たれます。

コンテキストを持つエラークラス

これを最大限に活用するには、外部エラーを有用なコンテキストでラップします:

class ExternalApiError < StandardError
  attr_reader :original_error, :retry_after, :retriable

  def initialize(message, original_error: nil, retry_after: nil, retriable: true)
    super(message)
    @original_error = original_error
    @retry_after = retry_after
    @retriable = retriable
  end

  def self.from_response(response)
    new(
      "API returned #{response.status}",
      retry_after: parse_retry_after(response),
      retriable: response.status.in?(500..599) || response.status == 429
    )
  end

  private_class_method def self.parse_retry_after(response)
    value = response.headers["Retry-After"]
    return nil unless value

    if value.match?(/^\d+$/)
      value.to_i.seconds
    else
      Time.httpdate(value) - Time.current rescue nil
    end
  end
end

そしてジョブはそれらの詳細に基づいて分岐できます:

class ApiSyncJob < ApplicationJob
  retry_on ExternalApiError,
    wait: ->(executions, error) {
      error.retry_after || (executions ** 2).seconds
    },
    attempts: 10

  def perform(resource)
    response = ApiClient.sync(resource)
    raise ExternalApiError.from_response(response) unless response.success?
  end
end

discard_onとの組み合わせ

すべてのエラーをリトライすべきではありません。決して成功しないエラーにはdiscard_onを使用します:

class ProcessPaymentJob < ApplicationJob
  discard_on PaymentDeclinedError  # 拒否されたカードはリトライしない

  retry_on PaymentGatewayError,
    wait: ->(executions, error) {
      error.retry_after || (10.seconds * executions)
    },
    attempts: 5

  def perform(order)
    PaymentGateway.charge(order)
  end
end

すべてのエラーを同じように扱う代わりに、特定の障害に適応するリトライ戦略を構築できるようになりました。ジョブはAPIの指示に従ってバックオフし、衝突を避けるためにリトライにジッターを追加し、リトライが役に立たない場合は素早く失敗できます。

この機能はRails 8.2で利用可能になります。