Skip to content

Commit e134348

Browse files
Anderson SilvaAnderson Silva
authored andcommitted
refactor: implement exponential backoff patterns and extract magic numbers to constants
- Extract HTTP timeout constant (60s for large file downloads) - Extract S3/MinIO retry configuration (3 retries, 1s-8s exponential backoff) - Extract database polling retry configuration (5 retries, 100ms-2s exponential backoff) - Extract webhook and lock polling delays to named constants - Extract cache TTL values (5min for messages, 30min for updates) in Baileys service - Implement exponential backoff for S3/MinIO downloads following webhook controller pattern - Implement exponential backoff for database polling removing fixed delays - Add deletion event lock to prevent race conditions with duplicate webhooks - Process deletion events immediately (no delay) to fix Chatwoot local storage red error - Make i18n translations path configurable via TRANSLATIONS_BASE_DIR env variable - Add detailed logging for deletion events debugging Addresses code review suggestions from Sourcery AI and Copilot AI: - Magic numbers extracted to well-documented constants - Retry configurations consolidated and clearly separated by use case - S3/MinIO retry uses longer delays (external storage) - Database polling uses shorter delays (internal operations) - Fixes Chatwoot local storage deletion error (red message issue) - Maintains full compatibility with S3/MinIO storage (tested) Breaking changes: None - all changes are internal improvements
1 parent 6e1d027 commit e134348

File tree

3 files changed

+111
-35
lines changed

3 files changed

+111
-35
lines changed

src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,10 @@ export class BaileysStartupService extends ChannelStartupService {
254254
private endSession = false;
255255
private logBaileys = this.configService.get<Log>('LOG').BAILEYS;
256256

257+
// Cache TTL constants (in seconds)
258+
private readonly MESSAGE_CACHE_TTL_SECONDS = 5 * 60; // 5 minutes - avoid duplicate message processing
259+
private readonly UPDATE_CACHE_TTL_SECONDS = 30 * 60; // 30 minutes - avoid duplicate status updates
260+
257261
public stateConnection: wa.StateConnection = { state: 'close' };
258262

259263
public phoneNumber: string;
@@ -1155,7 +1159,7 @@ export class BaileysStartupService extends ChannelStartupService {
11551159
continue;
11561160
}
11571161

1158-
await this.baileysCache.set(messageKey, true, 5 * 60);
1162+
await this.baileysCache.set(messageKey, true, this.MESSAGE_CACHE_TTL_SECONDS);
11591163

11601164
if (
11611165
(type !== 'notify' && type !== 'append') ||
@@ -1275,7 +1279,7 @@ export class BaileysStartupService extends ChannelStartupService {
12751279
await this.updateMessagesReadedByTimestamp(remoteJid, timestamp);
12761280
}
12771281

1278-
await this.baileysCache.set(messageKey, true, 5 * 60);
1282+
await this.baileysCache.set(messageKey, true, this.MESSAGE_CACHE_TTL_SECONDS);
12791283
} else {
12801284
this.logger.info(`Update readed messages duplicated ignored [avoid deadlock]: ${messageKey}`);
12811285
}
@@ -1459,7 +1463,7 @@ export class BaileysStartupService extends ChannelStartupService {
14591463
}
14601464

14611465
if (!isDeletedMessage) {
1462-
await this.baileysCache.set(updateKey, true, 30 * 60);
1466+
await this.baileysCache.set(updateKey, true, this.UPDATE_CACHE_TTL_SECONDS);
14631467
}
14641468

14651469
if (status[update.status] === 'READ' && key.fromMe) {
@@ -1543,7 +1547,7 @@ export class BaileysStartupService extends ChannelStartupService {
15431547
if (status[update.status] === status[4]) {
15441548
this.logger.log(`Update as read in message.update ${remoteJid} - ${timestamp}`);
15451549
await this.updateMessagesReadedByTimestamp(remoteJid, timestamp);
1546-
await this.baileysCache.set(messageKey, true, 5 * 60);
1550+
await this.baileysCache.set(messageKey, true, this.MESSAGE_CACHE_TTL_SECONDS);
15471551
}
15481552

15491553
await this.prismaRepository.message.update({

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

Lines changed: 90 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,25 @@ interface ChatwootMessage {
4444
export class ChatwootService {
4545
private readonly logger = new Logger('ChatwootService');
4646

47+
// HTTP timeout constants
48+
private readonly MEDIA_DOWNLOAD_TIMEOUT_MS = 60000; // 60 seconds for large files
49+
50+
// S3/MinIO retry configuration (external storage - longer delays, fewer retries)
51+
private readonly S3_MAX_RETRIES = 3;
52+
private readonly S3_BASE_DELAY_MS = 1000; // Base delay: 1 second
53+
private readonly S3_MAX_DELAY_MS = 8000; // Max delay: 8 seconds
54+
55+
// Database polling retry configuration (internal DB - shorter delays, more retries)
56+
private readonly DB_POLLING_MAX_RETRIES = 5;
57+
private readonly DB_POLLING_BASE_DELAY_MS = 100; // Base delay: 100ms
58+
private readonly DB_POLLING_MAX_DELAY_MS = 2000; // Max delay: 2 seconds
59+
60+
// Webhook processing delay
61+
private readonly WEBHOOK_INITIAL_DELAY_MS = 500; // Initial delay before processing webhook
62+
63+
// Lock polling delay
64+
private readonly LOCK_POLLING_DELAY_MS = 300; // Delay between lock status checks
65+
4766
private provider: any;
4867

4968
constructor(
@@ -617,7 +636,7 @@ export class ChatwootService {
617636
this.logger.warn(`Timeout aguardando lock para ${remoteJid}`);
618637
break;
619638
}
620-
await new Promise((res) => setTimeout(res, 300));
639+
await new Promise((res) => setTimeout(res, this.LOCK_POLLING_DELAY_MS));
621640
if (await this.cache.has(cacheKey)) {
622641
const conversationId = (await this.cache.get(cacheKey)) as number;
623642
this.logger.verbose(`Resolves creation of: ${remoteJid}, conversation ID: ${conversationId}`);
@@ -1136,7 +1155,7 @@ export class ChatwootService {
11361155
// maxRedirects: 0 para não seguir redirects automaticamente
11371156
const response = await axios.get(media, {
11381157
responseType: 'arraybuffer',
1139-
timeout: 60000, // 60 segundos de timeout para arquivos grandes
1158+
timeout: this.MEDIA_DOWNLOAD_TIMEOUT_MS,
11401159
headers: {
11411160
api_access_token: this.provider.token,
11421161
},
@@ -1154,18 +1173,19 @@ export class ChatwootService {
11541173
if (redirectUrl) {
11551174
// Fazer novo request para a URL do S3/MinIO (sem autenticação, pois é presigned URL)
11561175
// IMPORTANTE: Chatwoot pode gerar a URL presigned ANTES de fazer upload
1157-
// Vamos tentar com retry se receber 404 (arquivo ainda não disponível)
1176+
// Vamos tentar com retry usando exponential backoff se receber 404 (arquivo ainda não disponível)
11581177
this.logger.verbose('Downloading from S3/MinIO...');
11591178

11601179
let s3Response;
11611180
let retryCount = 0;
1162-
const maxRetries = 3;
1163-
const retryDelay = 2000; // 2 segundos entre tentativas
1181+
const maxRetries = this.S3_MAX_RETRIES;
1182+
const baseDelay = this.S3_BASE_DELAY_MS;
1183+
const maxDelay = this.S3_MAX_DELAY_MS;
11641184

11651185
while (retryCount <= maxRetries) {
11661186
s3Response = await axios.get(redirectUrl, {
11671187
responseType: 'arraybuffer',
1168-
timeout: 60000, // 60 segundos para arquivos grandes
1188+
timeout: this.MEDIA_DOWNLOAD_TIMEOUT_MS,
11691189
validateStatus: (status) => status < 500,
11701190
});
11711191

@@ -1178,14 +1198,16 @@ export class ChatwootService {
11781198
break;
11791199
}
11801200

1181-
// Se for 404 e ainda tem tentativas, aguardar e tentar novamente
1201+
// Se for 404 e ainda tem tentativas, aguardar com exponential backoff e tentar novamente
11821202
if (retryCount < maxRetries) {
1203+
// Exponential backoff com max delay (seguindo padrão do webhook controller)
1204+
const backoffDelay = Math.min(baseDelay * Math.pow(2, retryCount), maxDelay);
11831205
const errorBody = s3Response.data?.toString ? s3Response.data.toString('utf-8') : s3Response.data;
11841206
this.logger.warn(
1185-
`File not yet available in S3/MinIO (attempt ${retryCount + 1}/${maxRetries + 1}). Retrying in ${retryDelay}ms...`,
1207+
`File not yet available in S3/MinIO (attempt ${retryCount + 1}/${maxRetries + 1}). Retrying in ${backoffDelay}ms with exponential backoff...`,
11861208
);
11871209
this.logger.verbose(`MinIO Response: ${errorBody}`);
1188-
await new Promise((resolve) => setTimeout(resolve, retryDelay));
1210+
await new Promise((resolve) => setTimeout(resolve, backoffDelay));
11891211
retryCount++;
11901212
} else {
11911213
// Última tentativa falhou
@@ -1246,8 +1268,10 @@ export class ChatwootService {
12461268

12471269
this.logger.verbose(`File name: ${fileName}, size: ${mediaBuffer.length} bytes`);
12481270
} catch (downloadError) {
1249-
this.logger.error('Error downloading media from: ' + media);
1250-
this.logger.error(downloadError);
1271+
this.logger.error('[MEDIA DOWNLOAD] ❌ Error downloading media from: ' + media);
1272+
this.logger.error(`[MEDIA DOWNLOAD] Error message: ${downloadError.message}`);
1273+
this.logger.error(`[MEDIA DOWNLOAD] Error stack: ${downloadError.stack}`);
1274+
this.logger.error(`[MEDIA DOWNLOAD] Full error: ${JSON.stringify(downloadError, null, 2)}`);
12511275
throw new Error(`Failed to download media: ${downloadError.message}`);
12521276
}
12531277

@@ -1357,7 +1381,32 @@ export class ChatwootService {
13571381

13581382
public async receiveWebhook(instance: InstanceDto, body: any) {
13591383
try {
1360-
await new Promise((resolve) => setTimeout(resolve, 500));
1384+
// IMPORTANTE: Verificar lock de deleção ANTES do delay inicial
1385+
// para evitar race condition com webhooks duplicados
1386+
let isDeletionEvent = false;
1387+
if (body.event === 'message_updated' && body.content_attributes?.deleted) {
1388+
isDeletionEvent = true;
1389+
const deleteLockKey = `${instance.instanceName}:deleteMessage-${body.id}`;
1390+
1391+
// Verificar se já está processando esta deleção
1392+
if (await this.cache.has(deleteLockKey)) {
1393+
this.logger.warn(`[DELETE] ⏭️ SKIPPING: Deletion already in progress for messageId: ${body.id}`);
1394+
return { message: 'already_processing' };
1395+
}
1396+
1397+
// Adquirir lock IMEDIATAMENTE por 30 segundos
1398+
await this.cache.set(deleteLockKey, true, 30);
1399+
1400+
this.logger.warn(
1401+
`[WEBHOOK-DELETE] Event: ${body.event}, messageId: ${body.id}, conversation: ${body.conversation?.id}`,
1402+
);
1403+
}
1404+
1405+
// Para deleções, processar IMEDIATAMENTE (sem delay)
1406+
// Para outros eventos, aguardar delay inicial
1407+
if (!isDeletionEvent) {
1408+
await new Promise((resolve) => setTimeout(resolve, this.WEBHOOK_INITIAL_DELAY_MS));
1409+
}
13611410

13621411
const client = await this.clientCw(instance);
13631412

@@ -1385,7 +1434,10 @@ export class ChatwootService {
13851434

13861435
// Processar deleção de mensagem ANTES das outras validações
13871436
if (body.event === 'message_updated' && body.content_attributes?.deleted) {
1388-
this.logger.verbose(`Processing message deletion from Chatwoot - messageId: ${body.id}`);
1437+
// Lock já foi adquirido no início do método (antes do delay)
1438+
const deleteLockKey = `${instance.instanceName}:deleteMessage-${body.id}`;
1439+
1440+
this.logger.warn(`[DELETE] 🗑️ Processing deletion - messageId: ${body.id}`);
13891441
const waInstance = this.waMonitor.waInstances[instance.instanceName];
13901442

13911443
// Buscar TODAS as mensagens com esse chatwootMessageId (pode ser múltiplos anexos)
@@ -1397,18 +1449,22 @@ export class ChatwootService {
13971449
});
13981450

13991451
if (messages && messages.length > 0) {
1400-
this.logger.verbose(`Found ${messages.length} message(s) to delete from Chatwoot message ${body.id}`);
1452+
this.logger.warn(`[DELETE] Found ${messages.length} message(s) to delete from Chatwoot message ${body.id}`);
1453+
this.logger.verbose(`[DELETE] Messages keys: ${messages.map((m) => (m.key as any)?.id).join(', ')}`);
14011454

14021455
// Deletar cada mensagem no WhatsApp
14031456
for (const message of messages) {
14041457
const key = message.key as ExtendedMessageKey;
1405-
this.logger.verbose(`Deleting WhatsApp message - keyId: ${key?.id}`);
1458+
this.logger.warn(
1459+
`[DELETE] Attempting to delete WhatsApp message - keyId: ${key?.id}, remoteJid: ${key?.remoteJid}`,
1460+
);
14061461

14071462
try {
14081463
await waInstance?.client.sendMessage(key.remoteJid, { delete: key });
1409-
this.logger.verbose(`Message ${key.id} deleted in WhatsApp successfully`);
1464+
this.logger.warn(`[DELETE] ✅ Message ${key.id} deleted in WhatsApp successfully`);
14101465
} catch (error) {
1411-
this.logger.error(`Error deleting message ${key.id} in WhatsApp: ${error}`);
1466+
this.logger.error(`[DELETE] ❌ Error deleting message ${key.id} in WhatsApp: ${error}`);
1467+
this.logger.error(`[DELETE] Error details: ${JSON.stringify(error, null, 2)}`);
14121468
}
14131469
}
14141470

@@ -1419,15 +1475,16 @@ export class ChatwootService {
14191475
chatwootMessageId: body.id,
14201476
},
14211477
});
1422-
this.logger.verbose(`${messages.length} message(s) removed from database`);
1478+
this.logger.warn(`[DELETE] ✅ SUCCESS: ${messages.length} message(s) deleted from WhatsApp and database`);
14231479
} else {
14241480
// Mensagem não encontrada - pode ser uma mensagem antiga que foi substituída por edição
14251481
// Nesse caso, ignoramos silenciosamente pois o ID já foi atualizado no banco
1426-
this.logger.verbose(
1427-
`Message not found for chatwootMessageId: ${body.id} - may have been replaced by an edited message`,
1428-
);
1482+
this.logger.warn(`[DELETE] ⚠️ WARNING: Message not found in DB - chatwootMessageId: ${body.id}`);
14291483
}
14301484

1485+
// Liberar lock após processar
1486+
await this.cache.delete(deleteLockKey);
1487+
14311488
return { message: 'deleted' };
14321489
}
14331490

@@ -1726,12 +1783,11 @@ export class ChatwootService {
17261783
`Updating message with chatwootMessageId: ${chatwootMessageIds.messageId}, keyId: ${key.id}, instanceId: ${instanceId}`,
17271784
);
17281785

1729-
// Aguarda um pequeno delay para garantir que a mensagem foi criada no banco
1730-
await new Promise((resolve) => setTimeout(resolve, 100));
1731-
1732-
// Verifica se a mensagem existe antes de atualizar
1786+
// Verifica se a mensagem existe antes de atualizar usando polling com exponential backoff
17331787
let retries = 0;
1734-
const maxRetries = 5;
1788+
const maxRetries = this.DB_POLLING_MAX_RETRIES;
1789+
const baseDelay = this.DB_POLLING_BASE_DELAY_MS;
1790+
const maxDelay = this.DB_POLLING_MAX_DELAY_MS;
17351791
let messageExists = false;
17361792

17371793
while (retries < maxRetries && !messageExists) {
@@ -1750,8 +1806,14 @@ export class ChatwootService {
17501806
this.logger.verbose(`Message found in database after ${retries} retries`);
17511807
} else {
17521808
retries++;
1753-
this.logger.verbose(`Message not found, retry ${retries}/${maxRetries}`);
1754-
await new Promise((resolve) => setTimeout(resolve, 200));
1809+
if (retries < maxRetries) {
1810+
// Exponential backoff com max delay (seguindo padrão do sistema)
1811+
const backoffDelay = Math.min(baseDelay * Math.pow(2, retries - 1), maxDelay);
1812+
this.logger.verbose(`Message not found, retry ${retries}/${maxRetries} in ${backoffDelay}ms`);
1813+
await new Promise((resolve) => setTimeout(resolve, backoffDelay));
1814+
} else {
1815+
this.logger.verbose(`Message not found after ${retries} attempts`);
1816+
}
17551817
}
17561818
}
17571819

src/utils/i18n.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,19 @@ import fs from 'fs';
33
import i18next from 'i18next';
44
import path from 'path';
55

6-
// Detect if running from dist/ (production) or src/ (development)
7-
const isProduction = fs.existsSync(path.join(process.cwd(), 'dist'));
8-
const baseDir = isProduction ? 'dist' : 'src/utils';
6+
// Make translations base directory configurable via environment variable
7+
const envBaseDir = process.env.TRANSLATIONS_BASE_DIR;
8+
let baseDir: string;
9+
10+
if (envBaseDir) {
11+
// Use explicitly configured base directory
12+
baseDir = envBaseDir;
13+
} else {
14+
// Fallback to auto-detection if env variable is not set
15+
const isProduction = fs.existsSync(path.join(process.cwd(), 'dist'));
16+
baseDir = isProduction ? 'dist' : 'src/utils';
17+
}
18+
919
const translationsPath = path.join(process.cwd(), baseDir, 'translations');
1020

1121
const languages = ['en', 'pt-BR', 'es'];

0 commit comments

Comments
 (0)