Análise do Fluxo de Envio de Mensagens
Documento técnico: análise detalhada do fluxo de envio de mensagens WhatsApp na Pilot Status, incluindo regras de negócio, sistemas de garantia de entrega, pontos de falha e sugestões de melhoria.
1. Visão Geral da Arquitetura
O sistema de envio é composto por três camadas principais que trabalham em conjunto:
┌───────────────────────────────────────────────────────────────────┐
│ CLIENTE (API Externa / Dashboard) │
│ POST /api/messages → Autenticação → Rate Limit → Validação │
└────────────────────────────┬──────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ FULLSTACK (Next.js) │
│ MessageService.send() → Cria registro no DB (status=QUEUED) │
│ → Enfileira job no BullMQ (Redis) │
└────────────────────────────┬──────────────────────────────────────┘
│
▼
┌───────────────────────────────────────────────────────────────────┐
│ WORKER (Node.js) │
│ processMessage() → Renderiza template → Chama Evolution API │
│ → Atualiza status DB → Dispara Webhooks │
└───────────────────────────────────────────────────────────────────┘
Tecnologias-chave:
| Componente | Tecnologia | |---|---| | Fila de mensagens | BullMQ + Redis | | Banco de dados | PostgreSQL via Prisma | | Provider WhatsApp | Evolution API | | Lock distribuído | Redis (SET NX) | | Retry | BullMQ (backoff fixo 30s) |
2. Fluxo Completo de Envio (Passo a Passo)
Fase 1 — Recepção e Validação (Fullstack)
- Autenticação: a requisição chega com
apiKey. A chave é validada e associa o tenant/projeto. - Rate Limit (
RateLimitService.check()):- Ambiente TEST: verifica se o destino é um número de perfil registrado (bypass livre). Se não, aplica limite diário de teste (
TEST_MODE_DAILY_LIMIT). - Ambiente LIVE: verifica limite mensal baseado no plano + pacotes comprados. No plano FREE, também aplica limite diário. Aciona Auto-Recharge automaticamente se habilitado e o saldo cair abaixo de um percentual configurado.
- Ambiente TEST: verifica se o destino é um número de perfil registrado (bypass livre). Se não, aplica limite diário de teste (
- Validação de Opt-In (
WhatsAppTransactionalOptInService.assertDestinationAuthorized()): verifica se o destinatário autorizou receber mensagens transacionais (pode ser pulado comskipOptInValidation). - Cálculo de
deliverUntil(prazo de expiração automática por categoria de template):OTP→ 2 minutosUTILITY→ 1 horaMARKETING→ 4 horas
- Criação do registro no banco com
status=QUEUED. - Enfileramento no BullMQ: job com
jobId=messageId,attempts=10,backoff=fixed/30s.
Fase 2 — Processamento pelo Worker
- Lock Redis distribuído (
lock:ps:message:<messageId>, TTL 60s, SET NX): garante que apenas uma instância do worker processe a mensagem por vez. Se não adquirir o lock, simplesmente ignora (sem erro). - Busca da mensagem no DB: verifica existência e
status=QUEUED. Se não for QUEUED, ignora (idempotência). - Verificação de expiração (
deliverUntil): se a mensagem expirou, marcaFAILEDe notifica webhook do cliente. - Renderização do template: substitui variáveis
{{variavel}}no corpo do template. Suporta texto simples e JSON com botões. - Validação da instância WhatsApp: determina o
evolutionInstance— vem do job, da mensagem, ou da variável de ambienteEVOLUTION_INSTANCE_NAME. - Deduplicação (apenas para a instância "Pilot Status"): usa
tryAcquirePilotStatusSendSlot()para evitar que o mesmomessageIdseja enviado mais de uma vez (proteção contra retentativas do BullMQ ou race conditions). Isso permite que o mesmo conteúdo seja enviado repetidamente se forem jobs/mensagens diferentes. - Simulação de digitação: chama
trySimulateWhatsAppTyping()antes do envio real, para uma experiência mais natural. - Chamada à Evolution API:
- Se há botões →
POST /message/sendButtons/:instance - Se não →
POST /message/sendText/:instance(comlinkPreviewse URL detectada)
- Se há botões →
- Verificação de estado da instância: se a resposta indicar
state=close/connecting, lança erro de desconexão. - Atualiza o banco: salva
evolutionKeyIdeevolutionInstanceId. O statusSENTé atualizado via webhook da Evolution API (confirmação assíncrona —SERVER_ACK). - Webhook do cliente: disparado em caso de falha com payload completo do evento.
Fase 3 — Sistemas de Garantia e Monitoramento
Reconciliador de Fila (message-reconciler.ts)
Executa em intervalos regulares (padrão: 1 hora, configurável via MESSAGE_RECONCILER_INTERVAL_MS). É protegido por lock Redis (ps:message-reconciler:lock, TTL 55s) para evitar execução concorrente em múltiplos workers.
O reconciliador percorre todas as mensagens QUEUED e:
- Expira mensagens com
deliverUntilultrapassado → marcaFAILED. - Falha mensagens com mais de 7 dias em QUEUED → marca
FAILEDcom "Falha por timeout de entrega". - Valida dados mínimos:
whatsappInstanceName,templateVersionId,destinationNumber,payload. - Reenfileira mensagens órfãs: se o job sumiu da fila (ex: Redis reiniciou), cria um novo job com
attempts=10. - Promove jobs "delayed" que já deveriam ter sido processados.
- Reprocessa jobs
completedoufailedque ainda estejam com a mensagem em QUEUED no banco.
Healthcheck de Instâncias (all-instances-healthcheck.ts)
Executa a cada 10 minutos via cron no BullMQ.
- Fase 1 – Verificação: para cada instância com
state=OPEN, checaconnectionState+presence.- Se
not_found(404): marca comoCLOSEno banco, sem tentar reiniciar. - Se desconectada: adiciona à lista de "disconnected".
- Se
- Fase 2 – Restart: chama
restartWhatsappInstance()para todas as desconectadas em paralelo. - Aguarda (
HEALTHCHECK_SLEEP_MS, padrão 2 min) para a instância se reconectar. - Fase 3 – Recheck e Alertas: verifica novamente as que ainda estão desconectadas:
- Se ainda offline: envia alerta via Pushover (desenvolvedor) + notificação WhatsApp ao admin do tenant.
- Usa deduplicação Redis (
ps:instance:disconnect-alert-sent:<nome>) para não reenviar o alerta repetidamente.
Validação Individual de Instâncias (whatsapp-instance-validate-sweep)
Executa a cada 5 minutos. Para cada instância OPEN no banco, verifica connectionState + presence. Se inválida, envia alerta ao admin.
Sincronização de Workers
A cada 60 segundos (configurável via WHATSAPP_QUEUE_WORKER_REFRESH_MS), o worker sincroniza a lista de filas ativas com as instâncias OPEN no banco. Isso garante que novas instâncias ganhem workers e instâncias removidas parem de ser processadas.
3. Regras de Negócio do Fluxo
| Regra | Detalhe |
|---|---|
| Um job por mensagem | jobId = messageId — sem duplicatas no BullMQ |
| Idempotência | Worker verifica status=QUEUED antes de processar |
| Lock por mensagem | Redis SET NX 60s impede processamento concorrente |
| Concorrência = 1 | Cada fila de instância tem concurrency=1 no worker |
| Delay aleatório | Após processar, sleep(30–46s) (salvo urgente) para simular comportamento humano |
| Prioridade | Jobs urgentes têm priority=1, normais priority=5 |
| 10 tentativas | attempts=10, backoff=fixed/30s (~5 min de retry total) |
| Instâncias separadas | Cada instância WhatsApp tem sua própria fila e worker |
4. Identificação de Pontos de Falha e Gargalos
🔴 Críticos
PF-01: Redis como Single Point of Failure
O Redis é o único broker de filas. Se o Redis cair:
- Nenhum novo job pode ser enfileirado.
- Workers param de processar.
- O reconciliador não consegue adquirir lock.
- Consequência: mensagens ficam "travadas" em
QUEUEDno banco até o Redis voltar.
PF-02: Worker sem persistência de estado
O worker não persiste quais jobs estão sendo processados localmente. Se o processo cair no meio do processamento:
- O job pode ter sido executado pela Evolution API mas o banco ainda marca como
QUEUED. - O lock Redis de 60s vai expirar e o reconciliador vai reenfileirar — podendo resultar em envio duplicado.
PF-03: Status SENT dependente de webhook assíncrono da Evolution
O status só vai para SENT quando a Evolution API envia o webhook SERVER_ACK de volta. Se esse webhook falhar (rede, reinício, etc.):
- A mensagem fica como
QUEUEDno banco mesmo tendo sido enviada com sucesso. - O reconciliador vai tentar reenviar após 7 dias → duplicata.
PF-04: Webhook do cliente sem retry
Em dispatchCustomerWebhook(), se o endpoint do cliente retornar erro ou timeout, o sistema apenas loga e segue em frente. Notificações de status perdidas não têm mecanismo de retry.
🟠 Importantes
PF-05: Expiração silenciosa por deliverUntil
Mensagens OTP expiram em 2 minutos. O reconciliador roda de hora em hora por padrão. Se o intervalo padrão for usado, mensagens OTP jamais serão processadas pelo reconciliador antes de expirar — dependem exclusivamente do BullMQ processar a tempo.
PF-06: Instância desconectada mantém mensagens em QUEUED por 7 dias
Quando a Evolution API retorna erro de desconexão, a mensagem volta para QUEUED (sem falhar) e fica esperando o reconciliador ou a reconexão. O healthcheck tenta reconectar, mas se a instância não reconectar, as mensagens ficam acumulando.
PF-07: Sem fila por tenant/projeto separados
Todos os projetos de um mesmo tenant/instância compartilham a mesma fila. Um burst de mensagens de um projeto pode atrasar mensagens de outro no mesmo tenant.
PF-08: coercePayload pode falhar silenciosamente
No reconciliador, se o payload da mensagem não for um JSON de objetos (coercePayload retorna null), a mensagem é marcada como FAILED com "Payload não disponível". Mensagens com bodyOverride=true têm payload com estrutura diferente e podem ser incorretamente descartadas pelo reconciliador.
PF-09: Lock do reconciliador com TTL curto (55s)
Se a reconciliação demorar mais de 55 segundos (ex: banco lento, muitas mensagens), o lock expira e um segundo worker pode iniciar uma reconciliação concorrente, causando reenfileiramento duplicado.
🟡 Menores
PF-10: Ausência de monitoramento de DLQ
Jobs que falharam todas as 10 tentativas vão para a Dead Letter Queue do BullMQ. Não há alarme automático quando jobs entram na DLQ.
PF-11: Delay aleatório pode atrasar mensagens urgentes do reconciliador
O delay de 30–46s é aplicado no finally do handler, mesmo em jobs criados pelo reconciliador que são urgentes por natureza (já atrasados).
PF-12: Risco de starving em concorrência=1
Com concurrency=1 por instância, mensagens se processam sequencialmente. Em cenários de alto volume, a fila pode crescer indefinidamente.
5. Diagrama de Estados da Mensagem
┌─────────────┐
│ QUEUED │ ◄─── Re-enfileirado pelo reconciliador
└──────┬──────┘
│
┌─────────────┼──────────────┐
│ │ │
Expirou Enviou com Erro de envio
deliverUntil sucesso (não-fatal)
│ │ │
▼ ▼ │
FAILED (aguarda webhook) │ (retry BullMQ)
│ │
Evolution envia │
SERVER_ACK webhook │
│ │
▼ │
SENT ◄──────────┘
│
Usuário lê
│
▼
READ
6. Sugestões de Melhoria para Máxima Resiliência
🚀 Alta Prioridade
ME-01: Implementar idempotência baseada em evolutionKeyId antes de reenfileirar
Problema: PF-02 e PF-03 — duplicatas por reenfileiramento sem saber se a Evolution já enviou.
Solução: Antes de reenfileirar via reconciliador, consultar a Evolution API pelo evolutionKeyId (se existir no banco) para verificar se a mensagem já foi enviada. Se confirmado, atualizar para SENT sem reenviar.
// No reconciliador, antes de reenfileirar:
if (msg.evolutionKeyId) {
const alreadySent = await checkEvolutionMessageStatus(msg.evolutionKeyId);
if (alreadySent) {
await prisma.message.update({ where: { id: msg.id }, data: { status: 'SENT', sentAt: new Date() } });
continue;
}
}
ME-02: Implementar retry com fila para webhooks do cliente
Problema: PF-04 — notificações perdidas.
Solução: Mover dispatchCustomerWebhook() para uma fila BullMQ separada (webhook-delivery) com próprias tentativas e backoff exponencial. Isso também evita que erros de webhook bloqueiem o processamento da mensagem.
// Em vez de await dispatchCustomerWebhook(...) diretamente:
await webhookQueue.add('deliver', { webhookId, event, payload }, {
attempts: 5,
backoff: { type: 'exponential', delay: 5_000 },
});
ME-03: Reconciliador com intervalo adaptativo para OTP
Problema: PF-05 — mensagens OTP expiram antes do reconciliador rodar.
Solução: Adicionar um segundo reconciliador de alta frequência (a cada 30–60s) apenas para mensagens OTP ou qualquer mensagem com deliverUntil próximo. O reconciliador existente continua para as demais.
ME-04: Persistir estado pós-chamada Evolution antes do ACK
Problema: PF-03 — banco não sabe que a mensagem foi enviada enquanto aguarda SERVER_ACK.
Solução: Adicionar campo evolutionSubmittedAt ao modelo Message. Atualizar esse campo imediatamente após a chamada bem-sucedida à Evolution API. O reconciliador deve respeitar esse campo e não retentar mensagens onde evolutionSubmittedAt IS NOT NULL.
⚡ Média Prioridade
ME-05: Redis com replicação (Sentinel ou Cluster)
Problema: PF-01 — Redis como SPOF.
Solução: Configurar Redis com Sentinel (mínimo) ou Cluster para HA. O código já suporta resolução de primário via resolvePrimaryRedisUrl(), então a mudança seria apenas na configuração do ambiente.
ME-06: Aumentar TTL do lock do reconciliador dinamicamente
Problema: PF-09 — lock expira em reconciliações longas.
Solução: Usar o padrão de renovação periódica de lock (lock extension/watchdog): uma goroutine renova o TTL do lock a cada ttl/2 segundos enquanto a função ainda executa.
export async function withMessageReconcileLock(params) {
// ... acquire lock ...
const extender = setInterval(() => connection.pexpire(key, ttlMs), ttlMs / 2);
try {
return await params.fn();
} finally {
clearInterval(extender);
// ... release lock ...
}
}
ME-07: Corrigir reconciliador para suportar bodyOverride
Problema: PF-08 — mensagens com bodyOverride têm payload estruturado diferente e são descartadas.
Solução: No reconciliador, verificar se o payload contém "bodyOverride":true. Se sim, usar o fluxo sendRaw ao reenfileirar em vez de exigir templateVersionId.
ME-08: Alertas automáticos para DLQ
Problema: PF-10 — falhas silenciosas na DLQ.
Solução: Adicionar listener no evento failed do BullMQ para quando attemptsMade >= maxAttempts (job entrou em DLQ). Disparar alerta via Pushover/notificação para que o time possa investigar.
worker.on('failed', (job, err) => {
if (job && job.attemptsMade >= job.opts.attempts) {
pushoverService.sendDeveloperAlert({ title: 'Mensagem em DLQ', ... });
}
});
ME-09: Dashboard de saúde das filas
Problema: Falta visibilidade operacional em tempo real.
Solução: Expor endpoint interno (ou via BullMQ Board) com métricas das filas: tamanho, jobs em delay, jobs failed, throughput/hora por instância. Alertar quando fila crescer além de threshold.
🔧 Baixa Prioridade
ME-10: Delay aleatório configurável por prioridade no reconciliador
Problema: PF-11 — delay desnecessário em mensagens já atrasadas.
Solução: Jobs criados pelo reconciliador ou com urgent=true devem ser marcados com um flag para que o handler aplique delayMs=0.
ME-11: Aumentar concorrência com controle por rate-limit da Evolution
Problema: PF-12 — concurrency=1 cria gargalo em alto volume.
Solução: Aumentar para concurrency=3–5 com rate-limiter baseado nos limites da Evolution API por instância. O BullMQ suporta rate-limiting nativo via rateLimit nas opções do worker.
ME-12: Circuit Breaker para a Evolution API
Solução: Implementar padrão de Circuit Breaker: se N chamadas seguidas à Evolution falharem, "abrir" o circuito por X segundos, marcando mensagens como QUEUED sem tentar, até que o circuito "feche" novamente.
ME-13: Retry com backoff exponencial em vez de fixo
Atual: backoff.type = 'fixed', delay = 30_000 (sempre 30s entre tentativas).
Melhoria: Usar backoff.type = 'exponential' para aguardar progressivamente mais entre tentativas (ex: 30s → 1min → 2min → 4min...), reduzindo pressão sobre a Evolution API em caso de interrupção prolongada.
7. Resumo dos Riscos por Severidade
| ID | Risco | Severidade | Impacto | |---|---|---|---| | PF-01 | Redis SPOF | 🔴 Crítico | Sistema para completamente | | PF-02 | Worker crash → duplicata | 🔴 Crítico | Mensagem enviada 2x | | PF-03 | Sem ACK → reenfileiramento | 🔴 Crítico | Mensagem enviada 2x | | PF-04 | Webhook sem retry | 🟠 Importante | Cliente não é notificado | | PF-05 | OTP expira antes do reconciliador | 🟠 Importante | Mensagem nunca chega | | PF-06 | Instância offline por 7 dias | 🟠 Importante | Volume de QUEUED cresce | | PF-07 | Sem isolamento de projetos | 🟠 Importante | Starvation entre projetos | | PF-08 | bodyOverride descartado | 🟠 Importante | Preview nunca reenviado | | PF-09 | Lock reconciliador curto | 🟡 Menor | Dupla reconciliação rara | | PF-10 | DLQ silenciosa | 🟡 Menor | Falhas passam despercebidas | | PF-11 | Delay em jobs atrasados | 🟡 Menor | Latência extra desnecessária | | PF-12 | Concorrência=1 gargalo | 🟡 Menor | Alto volume acumula fila |
8. Roadmap de Resiliência (Ordem de Implementação Sugerida)
Semana 1-2 (Crítico):
✅ ME-04: Campo evolutionSubmittedAt
✅ ME-01: Verificar status na Evolution antes de reenfileirar
✅ ME-02: Fila de entrega de webhooks com retry
Semana 3-4 (Importante):
✅ ME-07: Corrigir reconciliador para bodyOverride
✅ ME-03: Reconciliador de alta frequência para OTP
✅ ME-06: Renovação de lock do reconciliador
✅ ME-08: Alertas automáticos DLQ
Mês 2 (Infraestrutura):
✅ ME-05: Redis com Sentinel/HA
✅ ME-09: Dashboard de saúde das filas
✅ ME-11: Concorrência aumentada com rate-limit
✅ ME-13: Backoff exponencial
Mês 3+ (Otimização):
✅ ME-10: Delay configurável por prioridade
✅ ME-12: Circuit Breaker para Evolution API
Documento gerado em: 2026-03-21
Analisado por: Antigravity (AI)
Arquivos analisados: send-message.ts, message-reconciler.ts, index.ts, all-instances-healthcheck.ts, message.service.ts, rate-limit.service.ts, message-reconcile-lock.ts