Skip to content

Commit 7f574ad

Browse files
committed
fix(chatwoot): improve message delivery reliability
- Adjust retry delays to fit within 40s webhook timeout (was 93s, now 29s) - Change from exponential to custom backoff: 0s, 3s, 6s, 10s, 10s - Throw error on failure so Chatwoot shows retry button - Apply to both text messages and attachments (images, videos, audios, documents) - Prevents message loss and reduces duplicates
1 parent 1059ed6 commit 7f574ad

3 files changed

Lines changed: 348 additions & 8 deletions

File tree

CHATWOOT_MESSAGE_LOSS_ANALYSIS.md

Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
# Análise: Perda de Mensagens Chatwoot → WhatsApp
2+
3+
## Resumo do Problema
4+
Mensagens enviadas do Chatwoot às vezes não chegam no WhatsApp dos destinatários. O problema afeta tanto mensagens de texto quanto imagens/anexos.
5+
6+
## Arquitetura Atual
7+
8+
### Fluxo de Mensagens (Chatwoot → WhatsApp)
9+
1. Webhook recebe mensagem do Chatwoot (`receiveWebhook` - linha 1604)
10+
2. Valida instância e tipo de mensagem
11+
3. Formata texto com assinatura do atendente
12+
4. Para cada mensagem:
13+
- **Anexos**: Chama `sendAttachment` (linha 1491) com retry
14+
- **Texto**: Chama `waInstance.textMessage` com retry
15+
16+
### Mecanismos de Proteção Existentes
17+
18+
#### 1. Retry com Exponential Backoff (linhas 47-85)
19+
```typescript
20+
// 5 tentativas: 3s, 6s, 12s, 24s, 48s
21+
retryWithBackoff(fn, maxAttempts=5, operationName, baseDelayMs=3000)
22+
```
23+
- ✅ Usado para: criar conversação, criar mensagem no Chatwoot
24+
- ❌ Retorna `null` em caso de falha (não lança exceção)
25+
- ❌ Mensagem é perdida se todas as tentativas falharem
26+
27+
#### 2. Cache Anti-Duplicação (linhas 90-130)
28+
```typescript
29+
// TTL de 5 minutos para evitar duplicatas
30+
messageDeduplicationCache.isDuplicate(messageId)
31+
```
32+
- ✅ Previne mensagens duplicadas
33+
- ⚠️ Pode bloquear retentativas legítimas
34+
35+
#### 3. Cache "Enviando" (linhas 1897, 1967)
36+
```typescript
37+
// Marca mensagem como "enviando" por 30 segundos
38+
await this.cache.set(`cw_sending_${body.id}`, true, 30);
39+
```
40+
- ✅ Previne envios simultâneos da mesma mensagem
41+
- ❌ Se falhar, cache expira mas mensagem não é reenviada
42+
43+
#### 4. Sistema de Recuperação (Cron)
44+
- `syncLostMessages`: Executa a cada 30 minutos
45+
- Recupera mensagens das últimas 6 horas que não foram sincronizadas
46+
47+
## Causas Prováveis de Perda de Mensagens
48+
49+
### 1. Falhas Silenciosas ⚠️ CRÍTICO
50+
**Localização**: `retryWithBackoff` (linha 47)
51+
52+
```typescript
53+
// Retorna null em vez de lançar exceção
54+
if (attempt === maxAttempts) {
55+
logger.error(`❌ ${operationName} falhou após ${maxAttempts} tentativas`);
56+
return null; // ❌ Mensagem perdida!
57+
}
58+
```
59+
60+
**Impacto**:
61+
- Mensagem falha após 5 tentativas (~45 segundos)
62+
- Retorna `null` sem lançar exceção
63+
- Código continua executando como se tudo estivesse OK
64+
- Mensagem só será recuperada pelo cron (30 minutos depois)
65+
66+
### 2. Erros no `sendAttachment` Não Capturados
67+
**Localização**: `sendAttachment` (linha 1491)
68+
69+
```typescript
70+
public async sendAttachment(...) {
71+
try {
72+
// ... código de envio
73+
} catch (error) {
74+
this.logger.error(error);
75+
throw error; // ✅ Re-lança erro
76+
}
77+
}
78+
```
79+
80+
**Problema**:
81+
- Método lança exceção em caso de erro
82+
- Mas o retry wrapper pode não capturar corretamente
83+
- Erros de rede/timeout podem não ser tratados
84+
85+
### 3. Cache Bloqueando Retentativas
86+
**Localização**: linhas 1897, 1967
87+
88+
```typescript
89+
const cacheKey = `cw_sending_${body.id}`;
90+
if (await this.cache.get(cacheKey)) {
91+
this.logger.warn('Mensagem já está sendo enviada, ignorando duplicata');
92+
return { message: 'already_sending' };
93+
}
94+
await this.cache.set(cacheKey, true, 30); // 30 segundos
95+
```
96+
97+
**Problema**:
98+
- Se primeira tentativa falhar, cache permanece por 30s
99+
- Webhook duplicado dentro de 30s será ignorado
100+
- Após 30s, cache expira mas ninguém retenta
101+
102+
### 4. Delays Intencionais (Anti-Ban)
103+
**Localização**: linhas 1531, 1556, 1963
104+
105+
```typescript
106+
// Áudio: delay aleatório 500-2000ms
107+
delay: Math.floor(Math.random() * (2000 - 500 + 1)) + 500
108+
109+
// Mídia: delay fixo 1200ms
110+
delay: 1200
111+
112+
// Texto: delay aleatório 500-2000ms
113+
delay: Math.floor(Math.random() * (2000 - 500 + 1)) + 500
114+
```
115+
116+
**Nota**: Estes delays são INTENCIONAIS para evitar ban do WhatsApp. Não são a causa da perda.
117+
118+
### 5. Timeout do Axios (45 segundos)
119+
**Localização**: linha 1455
120+
121+
```typescript
122+
timeout: 45000, // 45s para enviar mídia ao Chatwoot
123+
```
124+
125+
**Problema**:
126+
- Timeout pode ser insuficiente para arquivos grandes
127+
- Conexão lenta pode causar timeout
128+
- Erro de timeout não é sempre recuperável
129+
130+
## Cenários de Perda de Mensagem
131+
132+
### Cenário 1: Falha Total de Conexão
133+
```
134+
1. Chatwoot envia webhook
135+
2. receiveWebhook inicia processamento
136+
3. waInstance.textMessage() falha (WhatsApp offline)
137+
4. Retry 1: falha (3s depois)
138+
5. Retry 2: falha (6s depois)
139+
6. Retry 3: falha (12s depois)
140+
7. retryWithBackoff retorna null
141+
8. onSendMessageError envia mensagem privada no Chatwoot
142+
9. ❌ Mensagem perdida até cron (30 min)
143+
```
144+
145+
### Cenário 2: Timeout em Anexo Grande
146+
```
147+
1. Chatwoot envia webhook com imagem 10MB
148+
2. sendAttachment inicia download
149+
3. Axios timeout após 45s
150+
4. Retry 1: timeout novamente
151+
5. Retry 2: timeout novamente
152+
6. Retry 3: timeout novamente
153+
7. Exceção lançada
154+
8. Cache removido
155+
9. onSendMessageError notifica Chatwoot
156+
10. ❌ Mensagem perdida até cron (30 min)
157+
```
158+
159+
### Cenário 3: Cache Bloqueando Retry
160+
```
161+
1. Chatwoot envia webhook (tentativa 1)
162+
2. Cache marca como "enviando" (30s)
163+
3. Envio falha após 10s
164+
4. Cache ainda ativo (20s restantes)
165+
5. Chatwoot reenvia webhook (tentativa 2)
166+
6. Cache detecta duplicata
167+
7. ❌ Mensagem ignorada
168+
8. Após 30s, cache expira
169+
9. ❌ Ninguém retenta, mensagem perdida até cron
170+
```
171+
172+
## Recomendações de Correção
173+
174+
### 1. Implementar Fila de Mensagens Persistente 🔥 PRIORIDADE ALTA
175+
```typescript
176+
// Usar Redis ou banco de dados para fila
177+
interface QueuedMessage {
178+
id: string;
179+
instanceId: string;
180+
chatwootMessageId: number;
181+
type: 'text' | 'attachment';
182+
data: any;
183+
attempts: number;
184+
lastAttempt: Date;
185+
nextRetry: Date;
186+
}
187+
188+
// Ao falhar após retry:
189+
await messageQueue.add({
190+
id: generateId(),
191+
instanceId: instance.instanceId,
192+
chatwootMessageId: body.id,
193+
type: 'text',
194+
data: { number: chatId, text: formatText },
195+
attempts: 0,
196+
nextRetry: new Date(Date.now() + 60000) // 1 minuto
197+
});
198+
```
199+
200+
### 2. Melhorar Tratamento de Erros
201+
```typescript
202+
// Em vez de retornar null, lançar exceção específica
203+
class MessageSendError extends Error {
204+
constructor(
205+
message: string,
206+
public readonly retryable: boolean,
207+
public readonly originalError?: any
208+
) {
209+
super(message);
210+
}
211+
}
212+
213+
// No retryWithBackoff:
214+
if (attempt === maxAttempts) {
215+
throw new MessageSendError(
216+
`Falhou após ${maxAttempts} tentativas`,
217+
true, // pode retentar
218+
error
219+
);
220+
}
221+
```
222+
223+
### 3. Ajustar Estratégia de Cache
224+
```typescript
225+
// Cache mais inteligente
226+
const cacheKey = `cw_sending_${body.id}`;
227+
const sendingInfo = await this.cache.get(cacheKey);
228+
229+
if (sendingInfo) {
230+
const elapsed = Date.now() - sendingInfo.startTime;
231+
232+
// Se passou mais de 60s, permite retry
233+
if (elapsed > 60000) {
234+
this.logger.warn('Retry após timeout de cache');
235+
await this.cache.delete(cacheKey);
236+
} else {
237+
return { message: 'already_sending' };
238+
}
239+
}
240+
241+
await this.cache.set(cacheKey, {
242+
startTime: Date.now(),
243+
attempts: (sendingInfo?.attempts || 0) + 1
244+
}, 60);
245+
```
246+
247+
### 4. Aumentar Timeout para Anexos Grandes
248+
```typescript
249+
// Calcular timeout baseado no tamanho do arquivo
250+
const fileSize = await getFileSize(attachment.data_url);
251+
const timeoutMs = Math.max(45000, fileSize / 1024 * 100); // 100ms por KB
252+
253+
const config = {
254+
// ...
255+
timeout: timeoutMs,
256+
maxContentLength: 50 * 1024 * 1024, // 50MB
257+
};
258+
```
259+
260+
### 5. Adicionar Logging Detalhado
261+
```typescript
262+
// Log estruturado para debug
263+
this.logger.log({
264+
event: 'message_send_attempt',
265+
messageId: body.id,
266+
chatId: chatId,
267+
type: 'text',
268+
attempt: attemptNumber,
269+
timestamp: new Date().toISOString()
270+
});
271+
272+
// Log de falha com contexto completo
273+
this.logger.error({
274+
event: 'message_send_failed',
275+
messageId: body.id,
276+
chatId: chatId,
277+
error: error.message,
278+
stack: error.stack,
279+
retryable: error.retryable,
280+
willRetry: attemptNumber < maxAttempts
281+
});
282+
```
283+
284+
### 6. Monitoramento e Alertas
285+
```typescript
286+
// Métricas para monitoramento
287+
interface MessageMetrics {
288+
sent: number;
289+
failed: number;
290+
retried: number;
291+
queued: number;
292+
avgLatency: number;
293+
}
294+
295+
// Alerta se taxa de falha > 5%
296+
if (metrics.failed / metrics.sent > 0.05) {
297+
await sendAlert('Alta taxa de falha em mensagens Chatwoot→WhatsApp');
298+
}
299+
```
300+
301+
## Próximos Passos
302+
303+
### Investigação Imediata
304+
1. ✅ Analisar logs de produção para identificar padrões de erro
305+
2. ✅ Verificar se `onSendMessageError` está sendo chamado
306+
3. ✅ Confirmar se cron `syncLostMessages` está recuperando mensagens
307+
4. ✅ Testar cenários de falha em ambiente de desenvolvimento
308+
309+
### Implementação (Ordem de Prioridade)
310+
1. 🔥 **CRÍTICO**: Implementar fila persistente de mensagens
311+
2. 🔥 **CRÍTICO**: Melhorar tratamento de erros (não retornar null)
312+
3. ⚠️ **ALTO**: Ajustar estratégia de cache anti-duplicação
313+
4. ⚠️ **ALTO**: Adicionar logging detalhado
314+
5. 📊 **MÉDIO**: Implementar métricas e monitoramento
315+
6. 📊 **MÉDIO**: Aumentar timeout para anexos grandes
316+
317+
### Testes Necessários
318+
- [ ] Teste de falha de conexão WhatsApp
319+
- [ ] Teste de timeout em anexo grande (>10MB)
320+
- [ ] Teste de webhook duplicado
321+
- [ ] Teste de recuperação via cron
322+
- [ ] Teste de carga (múltiplas mensagens simultâneas)
323+
324+
## Conclusão
325+
326+
A perda de mensagens ocorre principalmente devido a:
327+
1. **Falhas silenciosas**: `retryWithBackoff` retorna `null` em vez de lançar exceção
328+
2. **Falta de fila persistente**: Mensagens falhadas não são enfileiradas para retry posterior
329+
3. **Cache bloqueando retries**: Webhooks duplicados são ignorados mesmo após falha
330+
331+
A solução mais efetiva é implementar uma **fila persistente de mensagens** que garanta que nenhuma mensagem seja perdida, mesmo em caso de falhas temporárias.

src/api/integrations/chatbot/chatwoot/services/chatwoot.service.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,16 +42,25 @@ interface ChatwootMessage {
4242
}
4343

4444
/**
45-
* Helper simples para retry com exponential backoff
45+
* Helper simples para retry com backoff customizado
46+
* Opção Balanceada: 4 retries em ~30s (dentro do timeout de 40s do Chatwoot)
47+
* Tentativa 1: imediato
48+
* Tentativa 2: +3s (total: 3s)
49+
* Tentativa 3: +6s (total: 9s)
50+
* Tentativa 4: +10s (total: 19s)
51+
* Tentativa 5: +10s (total: 29s)
4652
*/
4753
async function retryWithBackoff<T>(
4854
fn: () => Promise<T>,
4955
maxAttempts: number = 5,
5056
operationName: string = 'Operação',
51-
baseDelayMs: number = 3000,
57+
baseDelayMs: number = 3000, // Não usado mais, mantido para compatibilidade
5258
): Promise<T> {
5359
const logger = new Logger('RetryHelper');
5460

61+
// Delays customizados para ficar dentro de 30s
62+
const delays = [0, 3000, 6000, 10000, 10000]; // 0s, 3s, 6s, 10s, 10s
63+
5564
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
5665
try {
5766
const result = await fn();
@@ -69,8 +78,7 @@ async function retryWithBackoff<T>(
6978
throw new Error(`Failed after ${maxAttempts} attempts: ${operationName} - ${errorMsg}`);
7079
}
7180

72-
// Exponential backoff: 3s, 6s, 12s, 24s...
73-
const delayMs = baseDelayMs * Math.pow(2, attempt - 1);
81+
const delayMs = delays[attempt] || 10000; // Fallback para 10s
7482

7583
logger.warn(
7684
`⚠️ ${operationName} falhou (tentativa ${attempt}/${maxAttempts}): ${errorMsg}. ` +
@@ -1873,7 +1881,7 @@ export class ChatwootService {
18731881

18741882
let messageSent: any;
18751883
try {
1876-
// 🔄 Retry automático para anexos (5 tentativas = ~93 segundos)
1884+
// 🔄 Retry automático para anexos (5 tentativas = ~29 segundos)
18771885
messageSent = await retryWithBackoff(
18781886
async () => {
18791887
const result = await this.sendAttachment(
@@ -1923,7 +1931,7 @@ export class ChatwootService {
19231931

19241932
let messageSent: any;
19251933
try {
1926-
// 🔄 Retry automático: 5 tentativas (~93 segundos total)
1934+
// 🔄 Retry automático: 5 tentativas (~29 segundos total)
19271935
messageSent = await retryWithBackoff(
19281936
async () => {
19291937
const result = await waInstance?.textMessage(data, true);
@@ -2022,7 +2030,8 @@ export class ChatwootService {
20222030
} catch (error) {
20232031
this.logger.error(error);
20242032

2025-
return { message: 'bot' };
2033+
// ❌ Retorna erro para o Chatwoot mostrar botão de reenvio
2034+
throw error;
20262035
}
20272036
}
20282037

0 commit comments

Comments
 (0)