Stratégies de Retry Intelligentes dans Rails avec Délais Basés sur les Erreurs
Un changement récent dans Rails permet à la logique de retry de vos jobs d’inspecter l’erreur réelle qui s’est produite. Cela ouvre des stratégies de retry qui étaient auparavant difficiles à implémenter.
L’Ancienne Méthode
Avant ce changement, les procs de retry_on ne recevaient que le nombre d’exécutions :
class ApiJob < ApplicationJob
retry_on ApiError, wait: ->(executions) { executions ** 2 }
def perform(endpoint)
ExternalApi.call(endpoint)
end
end
Cela fonctionne pour un backoff exponentiel basique, mais que faire si l’API vous dit exactement quand réessayer ? Les APIs avec limitation de taux incluent souvent un header Retry-After. Avec seulement le nombre d’exécutions, vous ne pouvez pas accéder à cette information.
La Nouvelle Méthode
PR #56601, tout juste fusionné dans Rails main, ajoute l’erreur comme second argument optionnel. Cela sera disponible dans 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
Maintenant vous pouvez inspecter l’erreur et prendre des décisions intelligentes. Le changement est rétrocompatible—les procs avec une arité de 1 continuent à recevoir uniquement le nombre d’exécutions.
Patterns
Voici quelques façons d’utiliser cela en pratique.
Pattern 1 : Respecter les Limites de Taux
Quand une API vous limite, elle vous dit souvent quand réessayer :
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) {
# Faire confiance aux indications de l'API, avec un fallback raisonnable
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
Cela respecte les signaux de contre-pression de l’API au lieu de la bombarder aveuglément.
Pattern 2 : Extraire des Indices de Retry des Messages d’Exception
Certaines exceptions encodent des informations utiles dans leur message. Par exemple, un timeout de verrou pourrait vous dire quelle ressource était en conflit :
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) {
# Si nous savons combien de temps nous avons attendu le verrou, attendre au moins ce temps
# avant de réessayer, plus un peu de jitter
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
# Extraire le temps d'attente si votre adaptateur de base de données le fournit
raise LockTimeoutError.new(e.message, lock_wait_time: extract_wait_time(e))
end
private
def extract_wait_time(error)
# Parser depuis le message d'erreur ou les métadonnées si disponible
error.message[/waited (\d+)s/, 1]&.to_i
end
end
Le délai de retry s’adapte maintenant à la contention réelle observée.
Pattern 3 : Délais Contextuels Basés sur les Détails de l’Erreur
Certaines erreurs portent un contexte qui devrait influencer le timing du retry :
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 # Limite de taux, reculer significativement
when 503 then 30.seconds # Service indisponible, backoff modéré
when 500..502, 504..599 then 10.seconds # Erreurs serveur, délai plus court
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
Cela traite un 503 différemment d’un 500, et les deux différemment d’un 429.
Pattern 4 : Stratégie Multi-Erreurs avec Logique Partagée
Pour les jobs qui peuvent échouer de plusieurs façons, centralisez votre logique de retry :
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
Cela maintient les politiques de retry cohérentes dans toute votre application.
Classes d’Erreur qui Portent du Contexte
Pour tirer le meilleur parti de cela, enveloppez les erreurs externes avec un contexte utile :
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
Ensuite votre job peut se ramifier selon ces détails :
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
Combiner avec discard_on
Toutes les erreurs ne devraient pas être réessayées. Utilisez discard_on pour les erreurs qui ne réussiront jamais :
class ProcessPaymentJob < ApplicationJob
discard_on PaymentDeclinedError # Ne pas réessayer les cartes refusées
retry_on PaymentGatewayError,
wait: ->(executions, error) {
error.retry_after || (10.seconds * executions)
},
attempts: 5
def perform(order)
PaymentGateway.charge(order)
end
end
Au lieu de traiter toutes les erreurs de la même façon, vous pouvez maintenant construire des stratégies de retry qui s’adaptent à l’échec spécifique. Vos jobs peuvent reculer quand les APIs le leur demandent, ajouter du jitter à leurs retries pour éviter les collisions, et échouer rapidement quand réessayer n’aidera pas.
Cette fonctionnalité sera disponible dans Rails 8.2.