Fluxo de envio de mensagens (Pilot Status)
Este documento descreve o fluxo atual de envio de mensagens (API → fila → worker → Evolution/WhatsApp → webhooks), as regras de validação e as garantias existentes. Ao final, lista gargalos/pontos de falha e recomendações para tornar o sistema mais resiliente (maximizando a probabilidade de entrega e evitando perdas/duplicidades).
Objetivo do fluxo
- Receber uma requisição de envio (via API Key).
- Validar permissões/regras (ambiente, template, opt-in, limites).
- Persistir uma “intenção de envio” em banco.
- Processar de forma assíncrona e resiliente (com retry) via fila.
- Enviar a mensagem via Evolution API (gateway WhatsApp).
- Atualizar status conforme confirmações assíncronas (webhook da Evolution).
Componentes e responsabilidades
- API (apps/fullstack): valida requisição, resolve template, decide instância WhatsApp e enfileira.
- Entrada principal:
POST /api/v1/messages/sendemapps/fullstack/src/app/api/v1/messages/send/route.ts.
- Entrada principal:
- Banco (Postgres/Prisma): guarda a entidade
messagee dados necessários para auditoria/status/reconciliação.- Escrita principal:
apps/fullstack/src/services/message.service.ts(MessageService.send).
- Escrita principal:
- Fila (BullMQ/Redis): armazena jobs “send-message” por instância WhatsApp.
- Criação/uso:
apps/fullstack/src/lib/queue.tse nomes empackages/shared/src/constants.ts.
- Criação/uso:
- Worker (apps/worker): consome jobs, renderiza template, simula “digitando…”, chama Evolution e aplica regras de retry.
- Processamento:
apps/worker/src/send-message.ts(processMessage). - Concurrency e atraso humano entre jobs:
apps/worker/src/index.ts(workerconcurrency: 1+randomDelayMs()).
- Processamento:
- Evolution Webhook (apps/fullstack): recebe
messages.update, mapeia status e atualiza a mensagem no banco.- Handler:
apps/fullstack/src/app/api/internal/webhook/route.ts.
- Handler:
- Webhooks do cliente (opcional): Pilot Status notifica o sistema do cliente sobre eventos (FAILED/DELIVERED/READ etc).
- Envio:
dispatchCustomerWebhookemapps/worker/src/send-message.tse no webhook handler.
- Envio:
Modelo mental: estados e garantias (hoje)
Estados de mensagem no banco
O status é armazenado no registro message (ex.: QUEUED, SENT, DELIVERED, READ, FAILED).
QUEUED: mensagem criada e pronta para ser processada/enviada.SENT: status setado pelo webhook da Evolution (mapeiaSERVER_ACK→SENT).DELIVERED: status setado pelo webhook (mapeiaDELIVERY_ACK→DELIVERED).READ: status setado pelo webhook (mapeiaREAD/PLAYED→READ).FAILED: falha terminal (decidida pelo worker, pelo reconciler, ou pelo webhook com statusERROR/FAILED).
Garantia prática do sistema
O design atual é, na prática, at-least-once (pelo menos uma tentativa), com mecanismos que tentam aumentar a chance de envio:
- Persistência em banco antes de enviar.
- Retry automático via BullMQ (até 10 tentativas com backoff fixo).
- Reconciler que re-enfileira mensagens que ficaram “presas” em
QUEUED. - Locks/dedup específicos para evitar alguns duplicados.
Observação importante: “at-least-once” implica que duplicidade é possível se não houver idempotência ponta a ponta (ver “Pontos de falha” e “Sugestões”).
Fluxo detalhado (ponta a ponta)
1) Entrada da API e validações
POST /api/v1/messages/send (apps/fullstack/src/app/api/v1/messages/send/route.ts)
Principais validações/regras:
- API key: requer header
x-api-keye valida emApiKeyService.validate. - LIVE aprovado: se
environment === LIVE, exigeproject.productionApproved. - Schema do payload: valida com
sendMessageSchema(@pilot-status/shared). - Regras de TEST:
- Em TEST, só permite enviar para números WhatsApp configurados nos perfis do tenant.
- Template:
- Resolve versão aprovada mais recente do template (aceita
templateIdou nome “friendly”). - Em LIVE, impede envio se a versão não estiver aprovada para produção.
- Retorna erros específicos para: template inexistente, sem versão aprovada, project mismatch, env mismatch etc.
- Resolve versão aprovada mais recente do template (aceita
- Escolha/validação da instância WhatsApp:
- Se a API key estiver vinculada a
whatsappInstanceId, a instância precisa existir e estarOPEN(senão retorna409). - Caso contrário, usa
EVOLUTION_INSTANCE_NAME(instância “default”). - Se não houver instância configurada, responde
500.
- Se a API key estiver vinculada a
- Categoria MARKETING exige número próprio:
- Se estiver usando a instância default (número do Pilot Status), bloqueia templates MARKETING.
- Opt-in transacional (quando requerido):
- Verifica autorização do destino via
WhatsAppTransactionalOptInService.assertDestinationAuthorized. - A validação é relevante em LIVE e na instância default, quando habilitada.
- Exceção: templates
OTPno número Pilot Status podem bypass (no endpoint).
- Verifica autorização do destino via
- Rate limit/limites de plano:
RateLimitService.checkpode retornar429.- Em LIVE, limites mensais (plano + pacotes) e, no plano FREE, limite diário.
- Em TEST, há limite diário e exceção para enviar ao número do profile.
Se tudo passar, chama MessageService.send(...) e retorna 202 com id, correlationId, status (normalmente QUEUED) e origin (displayName ou instanceName).
2) Persistência e enqueue do job
MessageService.send em apps/fullstack/src/services/message.service.ts
- Cria registro
messagecom:status = QUEUEDcorrelationIddeliverBy(default: agora, oudeliverAt)deliverUntil(default por categoria):OTP: 2 minUTILITY: 1 horaMARKETING: 4 horas
- Enfileira BullMQ:
- Nome do job:
send-message jobId = message.id(ajuda a impedir jobs duplicados para a mesma mensagem)attempts = 10backoff fixed = 30sdelayquandodeliverAtestá no futuroprioritymenor quandourgent
- Nome do job:
Observação: o nome real da fila é por instância (messages--{instance}), calculado por getQueueNameForWhatsAppInstance em packages/shared/src/constants.ts. Isso significa que tudo que usa a mesma instância WhatsApp compartilha a mesma fila, independente do tenant.
3) Consumo no worker e envio via Evolution
processMessage em apps/worker/src/send-message.ts
Passos principais:
- Lock distribuído (Redis) por messageId:
SET NX EX 60com chavelock:ps:message:{messageId}.- Se não adquirir, o job é ignorado.
- Carrega a mensagem do banco e valida:
- Se não existe ou
status !== QUEUED: ignora. - Se
deliverUntilexpirou: marcaFAILEDe (se houver webhook de cliente) emitemessage.failed.
- Se não existe ou
- Renderiza conteúdo:
- Se
templateVersionexiste: renderiza{{variavel}}no body (incluindo botões).
- Se
- Simula “digitando…” (best-effort):
- Usa helper
trySimulateWhatsAppTyping(packages/shared/src/whatsapp-typing.ts) para chamar/chat/sendPresence.
- Usa helper
- Escolhe endpoint Evolution:
- Texto:
/message/sendText/{instance} - Botões:
/message/sendButtons/{instance} - Se encontrar link no texto, ativa
linkPreview.
- Texto:
- Retry/falhas:
- Erros são classificados de forma heurística (por substring) em “NotFound” e “Disconnected”.
NotFound→ marcaFAILED, tenta marcar instânciaCLOSEno banco e disparamessage.failed.- Erro genérico:
- Se não é disconnected e é tentativa final: marca
FAILED, disparamessage.failede falha o job. - Se é disconnected (ou ainda há tentativas): mantém
QUEUEDe:- Se ainda há tentativas: falha o job para BullMQ retry.
- Se acabou tentativas: não falha o job; mantém
QUEUEDpara o reconciler retentar depois.
- Se não é disconnected e é tentativa final: marca
- Após sucesso no HTTP 2xx da Evolution:
- Atualiza no banco
payload(se não redigir PII) e gravaevolutionKeyId/evolutionInstanceIdquando disponíveis. - Não altera o status para
SENT: o sistema “aguarda o webhook SERVER_ACK” para confirmarSENT.
- Atualiza no banco
4) Atualização de status via webhook da Evolution
Handler em apps/fullstack/src/app/api/internal/webhook/route.ts
- Processa
messages.update. - Mapeia status:
SERVER_ACK→SENTDELIVERY_ACK→DELIVEREDREAD/PLAYED→READERROR/FAILED→FAILED
- Resolve a mensagem por:
messageId(se vier compatível com o ID do Pilot Status), senãoevolutionKeyId(+instanceIdquando possível), senão- fallback por
evolutionKeyIdsomente.
- Aplica regra de progressão de status (não regredir
READ → SENT, etc). - Emite webhooks do cliente (quando ativo) principalmente para
FAILED,DELIVERED,READ(e evita duplicar evento).
Regras relevantes (resumo)
Regras de acesso/ambiente
x-api-keyobrigatório.LIVEexige aprovação de produção do projeto.- Templates em
LIVEprecisam estar aprovados para produção.
Regras por instância WhatsApp
- Se API key estiver vinculada a uma instância, ela precisa estar
OPENpara permitir enqueue. - Templates
MARKETINGnão podem usar o número default (Pilot Status).
Opt-in transacional (quando aplicável)
- Só é obrigatório quando:
- validação habilitada (
WHATSAPP_OPTIN_VALIDATION_ENABLED≠ disabled), - instância é a default,
- ambiente é LIVE,
- existe
projectId.
- validação habilitada (
- Caso contrário, é “skipped” (authorized).
Limites (rate limit)
TEST: limite diário com exceção para enviar ao número do profile.LIVE: limite mensal por plano/pacote; no plano FREE pode haver limite diário.
Janela de entrega (deliverBy / deliverUntil)
deliverBy: quando a mensagem “pode” ser enviada (gera delay no job).deliverUntil: prazo máximo para tentar entregar; ao expirar, a mensagem é marcada comoFAILED.
Mecanismos atuais para “garantir envio”
- Persistência antes do envio (registro no banco).
- BullMQ retries: 10 tentativas com backoff fixo (30s).
- Reconciler (
apps/worker/src/message-reconciler.ts):- Varre mensagens
QUEUED“vencidas” (deliverBy ≤ agora) e:- Enfileira se o job não existe.
- Promove job delayed se já venceu.
- Se job está
completedoufailed, remove e re-enfileira.
- Marca
FAILEDsedeliverUntilexpirou. - Marca
FAILEDpor timeout “longo” (default 7 dias) se necessário.
- Varre mensagens
- Lock Redis por messageId: evita processamento concorrente do mesmo
messageId. - Dedup especial do número Pilot Status:
- Evita enviar repetidamente a “mesma última mensagem” para o mesmo número (baseado em hash do texto).
Quando “realmente dá falha” hoje
Falha terminal (vira FAILED) acontece quando:
- Validações na API falham (nunca entra na fila):
- API key inválida, produção não aprovada, template inválido/não aprovado, instância não conectada, opt-in negado, rate limit.
- Expiração por prazo:
deliverUntilexpirou no worker ou reconciler.
- Configuração inválida no worker:
- Evolution não configurada/instância ausente (alguns casos viram
FAILED).
- Evolution não configurada/instância ausente (alguns casos viram
- Instância não encontrada:
- Worker identifica “not found” e falha definitivamente.
- Erro “genérico” na Evolution na última tentativa (não classificado como desconexão):
- Worker marca
FAILEDe falha o job.
- Worker marca
- Falhas internas inesperadas:
- Worker marca
FAILEDno catch geral.
- Worker marca
Importante: “falhar no Pilot Status” ≠ “não chegar no WhatsApp”.
- A mensagem pode ser aceita pela Evolution e ainda assim não ser entregue/visualizada por razões fora do controle (bloqueio, número inválido real, instabilidade WhatsApp, restrições do dispositivo, etc). O sistema depende de webhooks
DELIVERY_ACK/READpara confirmar.
Gargalos e pontos de falha (onde uma mensagem pode não chegar)
1) Throughput por instância: concurrency=1 + delay 30–46s
No worker, cada fila/instância roda com concurrency: 1 e ainda impõe um randomDelayMs() (≈ 30–46s) após processar cada job (apps/worker/src/index.ts). Isso:
- Reduz drasticamente o volume por instância (pode criar backlog grande).
- Aumenta o risco de
deliverUntilexpirar, especialmente paraOTP(2 min). - Faz com que um tenant “barulhento” possa impactar outros tenants que compartilham a mesma instância.
2) Dependência de webhook para sair de QUEUED (risco de reenvio)
Após a Evolution aceitar o envio (HTTP 2xx), o worker não muda o status para SENT; ele “aguarda SERVER_ACK” via webhook.
Se o webhook não chegar (instabilidade, perda, endpoint fora), a mensagem fica em QUEUED. O reconciler, por padrão, roda periodicamente e:
- se detectar que o job está
completed, remove e re-enfileira a mesma mensagem.
Consequência: duplicidade de envio é possível (o sistema pode reenviar uma mensagem que já foi aceita pela Evolution, mas cujo ACK não chegou ao backend).
3) Falta de timeout nas chamadas HTTP (Evolution e webhooks do cliente)
As chamadas fetch(...) não usam timeout explícito. Se houver hang de rede:
- um job pode ficar preso por tempo indeterminado,
- o lock Redis (TTL 60s) pode expirar no meio,
- outro processamento pode ocorrer depois e gerar duplicidade,
- a fila pode parar de progredir (concurrency=1).
4) Lock Redis com TTL curto (60s)
O lock por mensagem usa TTL fixo de 60s. Com:
- typing delay,
- latência alta,
- e ausência de timeout,
o job pode exceder 60s e o lock expirar, abrindo espaço para duplicidade em cenários de reprocessamento/re-enfileiramento.
5) Heurística frágil para “disconnected”
A classificação de desconexão usa includes(...) em texto do erro. Isso é frágil:
- pode classificar errado (falso positivo/negativo),
- pode fazer retry infinito em erro permanente,
- ou falhar cedo demais em erro transitório.
6) Retenção 0 (PII redacted) pode reduzir capacidade de reconciliação
Quando retentionDays <= 0, o sistema redige destinationNumber e payload no banco.
Isso cria um trade-off:
- O job original na fila ainda contém
destinationNumber/payload, então o envio pode funcionar. - Porém, se houver perda do job (Redis restart sem persistência adequada) ou necessidade de reenfileirar via reconciler, o reconciler pode não conseguir reconstruir o job (e marcar
FAILEDpor “Destino/Payload não disponível”).
7) Rate limit/contabilização depende de sentAt (ACK)
O rate limit contabiliza mensagens por sentAt (que é setado no webhook SERVER_ACK). Se webhooks atrasarem/falharem:
- mensagens enviadas podem não entrar na contagem,
- pode haver envio acima do esperado,
- e pode distorcer billing/limites/alertas.
Sugestões para melhorar resiliência (priorizado)
A) Evitar duplicidade por falta de ACK (alta prioridade)
-
Introduzir estado intermediário “DESPACHADA/ACEITA” (ex.:
DISPATCHEDouSENT_PENDING_ACK)- Ao receber HTTP 2xx da Evolution, atualizar
message.statuspara um estado intermediário e registrardispatchedAt,dispatchAttempt. - O webhook
SERVER_ACKpromoveria paraSENT. - O reconciler deve ignorar mensagens nesse estado por um “grace period” (ex.: 15–60 min) antes de reenfileirar.
- Ao receber HTTP 2xx da Evolution, atualizar
-
Ajustar o reconciler para considerar “prova de despacho”
- Se
evolutionKeyIdjá está setado, tratar como “provavelmente já enviada” e não reenfileirar agressivamente.
- Se
-
Se a Evolution suportar idempotência, enviar um idempotency key
- Ex.: enviar
messageIdcomo identificador na request (se houver campo suportado). - Assim, retries/reconciler não causariam duplicidade real no WhatsApp.
- Ex.: enviar
B) Timeouts e cancelamento de chamadas HTTP (alta prioridade)
- Adicionar timeout com
AbortControllerem:- chamadas para Evolution (
sendText/sendButtons,sendPresence,connectionState), - webhooks do cliente.
- chamadas para Evolution (
- Definir timeouts diferentes:
- typing/presence: curto (2–5s),
- envio: médio (10–20s),
- webhook do cliente: curto-médio (5–10s).
C) Retry mais inteligente (alta prioridade)
-
Basear retry em status code e tipo de erro (não em substring)
- 5xx/timeout/429: retry com backoff.
- 401/403 (apikey inválida), 400 (payload inválido): falha terminal imediata.
- 404 instância inexistente: falha terminal + sinalizar instância.
-
Usar backoff exponencial com jitter
- Evita thundering herd e melhora recuperação em incidentes.
-
Definir um “budget” de tentativas por janela (deliverUntil)
- Ex.: para OTP, tentar mais vezes no começo (rápido), e desistir cedo.
D) Melhorar lock/idempotência no worker (média prioridade)
- Aumentar TTL do lock (ou renovar lock durante processamento).
- Registrar
lastAttemptAteattemptCountno banco para ajudar reconciliação e observabilidade. - Considerar usar mecanismos de lock do próprio BullMQ (ou Redlock), evitando TTL curto “hardcoded”.
E) Throughput e qualidade de serviço por instância (média prioridade)
- Trocar
randomDelayMs()fixo por um “rate limiter” por instância:- token bucket/leaky bucket,
- configurável por ambiente/categoria (OTP vs marketing),
- com prioridade real (OTP > utility > marketing).
- Considerar
concurrency > 1em instâncias “próprias” do cliente (não a default), mantendo limites para evitar bloqueios do WhatsApp. - Garantir fairness entre tenants que compartilham instância (ex.: prioridades/quotas).
F) Resiliência com retenção 0 (trade-off PII vs confiabilidade)
Se o objetivo for “tentar ao máximo” mesmo com retenção 0:
- Criar um armazenamento transitório e seguro para
destinationNumber/payload:- tabela “outbox” com criptografia e TTL curto,
- ou Redis com persistência (AOF) + criptografia no payload,
- limpando assim que
SENT/DELIVEREDocorrer.
G) Observabilidade e operação (média prioridade)
- Métricas:
- latência
queued → dispatched → sentAck → delivered/read, - profundidade de fila por instância,
- taxa de retries e erros por status code,
- volume de
QUEUEDacima de X minutos.
- latência
- Alertas:
- webhook da Evolution sem eventos por N minutos,
- reconciler reenfileirando muitas mensagens repetidas,
- instância com muitos “disconnected” seguidos.
- Ferramentas operacionais:
- reprocessar manualmente uma mensagem,
- “pause sending” por instância,
- DLQ (dead letter queue) explícita para análise.
Conclusão
O fluxo atual já possui pilares importantes de resiliência (persistência + fila + retries + reconciler). O principal risco para “sempre tentar enviar ao máximo” sem efeitos colaterais é a dependência do webhook para tirar a mensagem de QUEUED, que pode levar a reenvios duplicados quando o ACK não chega. Ao introduzir um estado intermediário de “despachada”, adicionar timeouts e melhorar a estratégia de retry, o sistema fica significativamente mais robusto e previsível, mantendo a capacidade de retentar agressivamente sem gerar duplicidades indesejadas.