Na pratica, BullMQ ja oferece um limiter nativo no Worker, que implementa o controle de taxa sobre o grupo de workers que compartilham a mesma fila e Redis. Voce define quantos jobs podem ser processados por janela de tempo, e a concorrencia controla o paralelismo dentro desse teto. O exemplo abaixo enfileira a campanha e consome respeitando 80 mensagens por segundo, com retry exponencial e DLQ para falhas definitivas.
const { Queue, Worker, QueueEvents } = require('bullmq');
const connection = { url: process.env.REDIS_URL };
// Throughput alvo: mantenha abaixo do limite real da Cloud API.
const MESSAGES_PER_SECOND = 80;
const campaignQueue = new Queue('wa-campaign', { connection });
const dlq = new Queue('wa-campaign-dlq', { connection });
// Produtor: enfileira 1 job por contato. A fila absorve a rajada.
async function enqueueCampaign(campaignId, contacts, template) {
const jobs = contacts.map((contact) => ({
name: 'send',
data: { campaignId, to: contact.phone, template },
opts: {
attempts: 5,
backoff: { type: 'exponential', delay: 2000 }, // 2s, 4s, 8s, 16s
removeOnComplete: true,
removeOnFail: false,
},
}));
await campaignQueue.addBulk(jobs);
}
// Worker pool: o limiter regula a SAIDA a X msg/s no grupo todo.
const worker = new Worker(
'wa-campaign',
async (job) => {
const { to, template } = job.data;
const res = await sendViaCloudApi(to, template);
// Respeita o Retry-After da Meta em vez de martelar a API.
if (res.status === 429) {
const retryAfter = Number(res.headers['retry-after'] || 1);
throw new RateLimitError(retryAfter * 1000);
}
if (res.status >= 500) throw new Error('Cloud API 5xx');
return res.body;
},
{
connection,
concurrency: 20, // paralelismo: protege recursos locais
limiter: {
max: MESSAGES_PER_SECOND, // teto de envios...
duration: 1000, // ...por segundo (token bucket)
},
},
);
// Falha definitiva apos esgotar os retries: vai para a DLQ, nao some.
worker.on('failed', async (job, err) => {
if (job && job.attemptsMade >= (job.opts.attempts || 1)) {
await dlq.add('dead-letter', {
payload: job.data,
error: err.message,
failedAt: new Date().toISOString(),
});
}
});
Note como o 429 nao e tratado como erro permanente: o worker lanca um erro de rate limit e o job volta para a fila com backoff, respeitando o Retry-After da Meta. So depois de esgotar todas as tentativas o evento vai para a DLQ. Isso e o que diferencia throttling temporario de falha real e evita perder entregas que sairiam minutos depois.