Estrategias Inteligentes de Reintento en Rails con Delays Basados en Errores

Un cambio reciente en Rails permite que la lógica de reintento de tus jobs inspeccione el error real que ocurrió. Esto abre estrategias de reintento que antes eran difíciles de implementar.

La Forma Anterior

Antes de este cambio, los procs de retry_on solo recibían el conteo de ejecuciones:

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

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

Esto funciona para backoff exponencial básico, pero ¿qué pasa si la API te dice exactamente cuándo reintentar? Las APIs con límite de tasa a menudo incluyen un header Retry-After. Con solo el conteo de ejecuciones, no puedes acceder a esa información.

La Nueva Forma

PR #56601, recién fusionado en Rails main, añade el error como un segundo argumento opcional. Esto estará disponible en 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

Ahora puedes inspeccionar el error y tomar decisiones inteligentes. El cambio es retrocompatible—los procs con aridad 1 continúan recibiendo solo el conteo de ejecuciones.

Patrones

Aquí hay algunas formas de usar esto en la práctica.

Patrón 1: Respetar Límites de Tasa

Cuando una API te limita la tasa, a menudo te dice cuándo reintentar:

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) {
      # Confía en la guía de la API, con un fallback sensato
      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

Esto respeta las señales de contrapresión de la API en lugar de bombardearla ciegamente.

Patrón 2: Extraer Pistas de Reintento de Mensajes de Excepción

Algunas excepciones codifican información útil en su mensaje. Por ejemplo, un timeout de bloqueo podría decirte qué recurso estaba en disputa:

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 sabemos cuánto tiempo esperamos por el bloqueo, espera al menos ese tiempo
      # antes de reintentar, más algo 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
    # Extrae el tiempo de espera si tu adaptador de base de datos lo proporciona
    raise LockTimeoutError.new(e.message, lock_wait_time: extract_wait_time(e))
  end

  private

  def extract_wait_time(error)
    # Parsea del mensaje de error o metadatos si está disponible
    error.message[/waited (\d+)s/, 1]&.to_i
  end
end

El delay de reintento ahora se adapta a la contención real observada.

Patrón 3: Delays Basados en Contexto Según Detalles del Error

Algunos errores llevan contexto que debería influir en el timing del reintento:

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  # Límite de tasa, retroceder significativamente
    when 503 then 30.seconds  # Servicio no disponible, backoff moderado
    when 500..502, 504..599 then 10.seconds  # Errores de servidor, delay más corto
    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

Esto trata un 503 diferente de un 500, y ambos diferente de un 429.

Patrón 4: Estrategia Multi-Error con Lógica Compartida

Para jobs que pueden fallar de múltiples formas, centraliza tu lógica de reintento:

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

Esto mantiene las políticas de reintento consistentes en toda tu aplicación.

Clases de Error que Llevan Contexto

Para aprovechar esto al máximo, envuelve los errores externos con contexto útil:

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

Entonces tu job puede ramificar según esos detalles:

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

Combinando con discard_on

No todos los errores deberían reintentarse. Usa discard_on para errores que nunca tendrán éxito:

class ProcessPaymentJob < ApplicationJob
  discard_on PaymentDeclinedError  # No reintentar tarjetas rechazadas

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

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

En lugar de tratar todos los errores igual, ahora puedes construir estrategias de reintento que se adaptan al fallo específico. Tus jobs pueden retroceder cuando las APIs se lo indican, añadir jitter a sus reintentos para evitar colisiones, y fallar rápido cuando reintentar no ayudará.

Esta característica estará disponible en Rails 8.2.