Reintentos y backoff exponencial en BullMQ

1 de julio de 2026 · 2 min de lectura

Construir PriceWatch significó aceptar una realidad incómoda: el scraping falla, seguido. Una tienda cambia su HTML, un proxy se cae, un timeout ocurre a mitad de una petición. La pregunta no es cómo evitar los fallos, sino cómo hacer que el sistema se recupere solo.

Por qué una cola y no un cron simple

Un cron que corre cada 10 minutos y hace scraping de forma secuencial se rompe apenas agregas la URL 41: el job anterior aún no termina cuando el siguiente debería empezar. Una cola con workers concurrentes desacopla "cuándo se agenda el trabajo" de "cuándo se ejecuta".

import { Queue } from "bullmq"
 
const scrapeQueue = new Queue("scrape-product", { connection: redisConnection })
 
await scrapeQueue.add(
  "scrape",
  { productId },
  {
    attempts: 5,
    backoff: { type: "exponential", delay: 2000 },
  },
)

Backoff exponencial en la práctica

Con delay: 2000 y backoff exponencial, los reintentos quedan así:

IntentoEspera antes de reintentar
1inmediato
22s
34s
48s
516s

Esto importa porque un fallo transitorio (un timeout de red, un rate-limit momentáneo) suele resolverse solo en segundos. Reintentar de inmediato en un loop ajustado solo empeora el rate-limit; esperar cada vez más le da tiempo al problema de desaparecer.

Diferenciar errores recuperables de errores permanentes

No todos los fallos deberían reintentarse. Si una tienda cambió su estructura HTML y el selector ya no existe, reintentar 5 veces solo retrasa lo inevitable.

worker.on("failed", (job, err) => {
  if (err instanceof SelectorNotFoundError) {
    // Error estructural: no tiene sentido reintentar automáticamente.
    job.discard()
    notifyMaintainer(job.data.productId, err)
  }
})

En la práctica separé los errores en dos categorías desde el inicio: transitorios (reintentables, dejan que BullMQ haga su trabajo) y estructurales (se marcan para revisión manual y generan una alerta). Esa distinción evitó que el sistema reintentara indefinidamente jobs que nunca iban a tener éxito.

Worker completo

Así se ve el worker completo, uniendo colas, reintentos, backoff y la separación de errores:

import { Worker, Queue, type Job } from "bullmq"
import { redisConnection } from "./redis"
import { scrapeProduct } from "./scraper"
import { notifyMaintainer } from "./notifications"
import { SelectorNotFoundError } from "./errors"
 
type ScrapeJobData = {
  productId: string
  url: string
}
 
export const scrapeQueue = new Queue<ScrapeJobData>("scrape-product", {
  connection: redisConnection,
})
 
export async function enqueueScrape(productId: string, url: string) {
  await scrapeQueue.add(
    "scrape",
    { productId, url },
    {
      attempts: 5,
      backoff: { type: "exponential", delay: 2000 },
      removeOnComplete: 1000,
      removeOnFail: 5000,
    },
  )
}
 
const worker = new Worker<ScrapeJobData>(
  "scrape-product",
  async (job: Job<ScrapeJobData>) => {
    const { productId, url } = job.data
    const result = await scrapeProduct(url)
 
    if (!result.price) {
      throw new Error(`No se pudo extraer el precio para ${productId}`)
    }
 
    return result
  },
  { connection: redisConnection, concurrency: 5 },
)
 
worker.on("completed", (job) => {
  console.log(`Job ${job.id} completado para producto ${job.data.productId}`)
})
 
worker.on("failed", (job, err) => {
  if (!job) return
 
  if (err instanceof SelectorNotFoundError) {
    job.discard()
    notifyMaintainer(job.data.productId, err)
    return
  }
 
  console.warn(`Job ${job.id} falló (intento ${job.attemptsMade}): ${err.message}`)
})