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で利用可能になります。