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.