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.