From a2c25bb6f6d7ab2a5d5797b00b81e0f1cecc3e3e Mon Sep 17 00:00:00 2001 From: Guilherme Jansen Date: Fri, 21 Mar 2025 14:42:23 -0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(webhook):=20adicionar=20interface=20pa?= =?UTF-8?q?ra=20configura=C3=A7=C3=A3o=20de=20timeout=20e=20retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona interface de configuração na estrutura Webhook para permitir: - Configuração de timeout em requisições - Parâmetros de retentativas configuráveis - Lista de códigos HTTP que não devem gerar retry Issue: #1325 --- src/config/env.config.ts | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/src/config/env.config.ts b/src/config/env.config.ts index 78ca891cd..c5b7d1917 100644 --- a/src/config/env.config.ts +++ b/src/config/env.config.ts @@ -220,7 +220,21 @@ export type CacheConfLocal = { TTL: number; }; export type SslConf = { PRIVKEY: string; FULLCHAIN: string }; -export type Webhook = { GLOBAL?: GlobalWebhook; EVENTS: EventsWebhook }; +export type Webhook = { + GLOBAL?: GlobalWebhook; + EVENTS: EventsWebhook; + REQUEST?: { + TIMEOUT_MS?: number; + }; + RETRY?: { + MAX_ATTEMPTS?: number; + INITIAL_DELAY_SECONDS?: number; + USE_EXPONENTIAL_BACKOFF?: boolean; + MAX_DELAY_SECONDS?: number; + JITTER_FACTOR?: number; + NON_RETRYABLE_STATUS_CODES?: number[]; + }; +}; export type Pusher = { ENABLED: boolean; GLOBAL?: GlobalPusher; EVENTS: EventsPusher }; export type ConfigSessionPhone = { CLIENT: string; NAME: string; VERSION: string }; export type QrCode = { LIMIT: number; COLOR: string }; @@ -497,6 +511,17 @@ export class ConfigService { ERRORS: process.env?.WEBHOOK_EVENTS_ERRORS === 'true', ERRORS_WEBHOOK: process.env?.WEBHOOK_EVENTS_ERRORS_WEBHOOK || '', }, + REQUEST: { + TIMEOUT_MS: Number.parseInt(process.env?.WEBHOOK_REQUEST_TIMEOUT_MS) || 30000, + }, + RETRY: { + MAX_ATTEMPTS: Number.parseInt(process.env?.WEBHOOK_RETRY_MAX_ATTEMPTS) || 10, + INITIAL_DELAY_SECONDS: Number.parseInt(process.env?.WEBHOOK_RETRY_INITIAL_DELAY_SECONDS) || 5, + USE_EXPONENTIAL_BACKOFF: process.env?.WEBHOOK_RETRY_USE_EXPONENTIAL_BACKOFF !== 'false', + MAX_DELAY_SECONDS: Number.parseInt(process.env?.WEBHOOK_RETRY_MAX_DELAY_SECONDS) || 300, + JITTER_FACTOR: Number.parseFloat(process.env?.WEBHOOK_RETRY_JITTER_FACTOR) || 0.2, + NON_RETRYABLE_STATUS_CODES: process.env?.WEBHOOK_RETRY_NON_RETRYABLE_STATUS_CODES?.split(',').map(Number) || [400, 401, 403, 404, 422], + }, }, CONFIG_SESSION_PHONE: { CLIENT: process.env?.CONFIG_SESSION_PHONE_CLIENT || 'Evolution API', From 5156ea58ac1c834f14986b3c54682bb49c8616d7 Mon Sep 17 00:00:00 2001 From: Guilherme Jansen Date: Fri, 21 Mar 2025 14:42:28 -0300 Subject: [PATCH 2/4] =?UTF-8?q?feat(env):=20adicionar=20vari=C3=A1veis=20d?= =?UTF-8?q?e=20ambiente=20para=20webhook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona novas variáveis para controlar o comportamento dos webhooks: - WEBHOOK_REQUEST_TIMEOUT_MS: tempo máximo de espera - WEBHOOK_RETRY_MAX_ATTEMPTS: número máximo de tentativas - WEBHOOK_RETRY_INITIAL_DELAY_SECONDS: intervalo inicial - WEBHOOK_RETRY_USE_EXPONENTIAL_BACKOFF: ativar backoff exponencial - WEBHOOK_RETRY_MAX_DELAY_SECONDS: intervalo máximo entre tentativas - WEBHOOK_RETRY_JITTER_FACTOR: fator de aleatoriedade - WEBHOOK_RETRY_NON_RETRYABLE_STATUS_CODES: códigos de erro permanentes Issue: #1325 --- .env.example | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.env.example b/.env.example index 02eca6123..42547ffc6 100644 --- a/.env.example +++ b/.env.example @@ -173,6 +173,16 @@ WEBHOOK_EVENTS_TYPEBOT_CHANGE_STATUS=false WEBHOOK_EVENTS_ERRORS=false WEBHOOK_EVENTS_ERRORS_WEBHOOK= +# Webhook timeout and retry configuration +WEBHOOK_REQUEST_TIMEOUT_MS=60000 +WEBHOOK_RETRY_MAX_ATTEMPTS=10 +WEBHOOK_RETRY_INITIAL_DELAY_SECONDS=5 +WEBHOOK_RETRY_USE_EXPONENTIAL_BACKOFF=true +WEBHOOK_RETRY_MAX_DELAY_SECONDS=300 +WEBHOOK_RETRY_JITTER_FACTOR=0.2 +# Comma separated list of HTTP status codes that should not trigger retries +WEBHOOK_RETRY_NON_RETRYABLE_STATUS_CODES=400,401,403,404,422 + # Name that will be displayed on smartphone connection CONFIG_SESSION_PHONE_CLIENT=Evolution API # Browser Name = Chrome | Firefox | Edge | Opera | Safari From e37a3cc2d6451bf97d4964bd0cf9c20c7e17df54 Mon Sep 17 00:00:00 2001 From: Guilherme Jansen Date: Fri, 21 Mar 2025 14:43:05 -0300 Subject: [PATCH 3/4] =?UTF-8?q?fix(webhook):=20adicionar=20timeout=20confi?= =?UTF-8?q?gur=C3=A1vel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa timeout configurável nas requisições de webhook: - Aplica configuração em todas as instâncias axios - Usa valor padrão de 30 segundos se não configurado - Evita requisições penduradas indefinidamente Issue: #1325 --- .../event/webhook/webhook.controller.ts | 61 ++++++++++++++++--- 1 file changed, 54 insertions(+), 7 deletions(-) diff --git a/src/api/integrations/event/webhook/webhook.controller.ts b/src/api/integrations/event/webhook/webhook.controller.ts index ce709c3d4..b77c78295 100644 --- a/src/api/integrations/event/webhook/webhook.controller.ts +++ b/src/api/integrations/event/webhook/webhook.controller.ts @@ -115,6 +115,7 @@ export class WebhookController extends EventController implements EventControlle const httpService = axios.create({ baseURL, headers: webhookHeaders as Record | undefined, + timeout: webhookConfig.REQUEST?.TIMEOUT_MS ?? 30000, }); await this.retryWebhookRequest(httpService, webhookData, `${origin}.sendData-Webhook`, baseURL, serverUrl); @@ -156,7 +157,10 @@ export class WebhookController extends EventController implements EventControlle try { if (isURL(globalURL)) { - const httpService = axios.create({ baseURL: globalURL }); + const httpService = axios.create({ + baseURL: globalURL, + timeout: webhookConfig.REQUEST?.TIMEOUT_MS ?? 30000, + }); await this.retryWebhookRequest( httpService, @@ -190,12 +194,21 @@ export class WebhookController extends EventController implements EventControlle origin: string, baseURL: string, serverUrl: string, - maxRetries = 10, - delaySeconds = 30, + maxRetries?: number, + delaySeconds?: number, ): Promise { + // Obter configurações de retry das variáveis de ambiente + const webhookConfig = configService.get('WEBHOOK'); + const maxRetryAttempts = maxRetries ?? webhookConfig.RETRY?.MAX_ATTEMPTS ?? 10; + const initialDelay = delaySeconds ?? webhookConfig.RETRY?.INITIAL_DELAY_SECONDS ?? 5; + const useExponentialBackoff = webhookConfig.RETRY?.USE_EXPONENTIAL_BACKOFF ?? true; + const maxDelay = webhookConfig.RETRY?.MAX_DELAY_SECONDS ?? 300; + const jitterFactor = webhookConfig.RETRY?.JITTER_FACTOR ?? 0.2; + const nonRetryableStatusCodes = webhookConfig.RETRY?.NON_RETRYABLE_STATUS_CODES ?? [400, 401, 403, 404, 422]; + let attempts = 0; - while (attempts < maxRetries) { + while (attempts < maxRetryAttempts) { try { await httpService.post('', webhookData); if (attempts > 0) { @@ -208,13 +221,30 @@ export class WebhookController extends EventController implements EventControlle return; } catch (error) { attempts++; + + // Verificar se é um erro de timeout + const isTimeout = error.code === 'ECONNABORTED'; + + // Verificar se o erro não deve gerar retry com base no status code + if (error?.response?.status && nonRetryableStatusCodes.includes(error.response.status)) { + this.logger.error({ + local: `${origin}`, + message: `Erro não recuperável (${error.response.status}): ${error?.message}. Cancelando retentativas.`, + statusCode: error?.response?.status, + url: baseURL, + server_url: serverUrl, + }); + throw error; + } this.logger.error({ local: `${origin}`, - message: `Tentativa ${attempts}/${maxRetries} falhou: ${error?.message}`, + message: `Tentativa ${attempts}/${maxRetryAttempts} falhou: ${isTimeout ? 'Timeout da requisição' : error?.message}`, hostName: error?.hostname, syscall: error?.syscall, code: error?.code, + isTimeout, + statusCode: error?.response?.status, error: error?.errno, stack: error?.stack, name: error?.name, @@ -222,11 +252,28 @@ export class WebhookController extends EventController implements EventControlle server_url: serverUrl, }); - if (attempts === maxRetries) { + if (attempts === maxRetryAttempts) { throw error; } - await new Promise((resolve) => setTimeout(resolve, delaySeconds * 1000)); + // Cálculo do delay com backoff exponencial e jitter + let nextDelay = initialDelay; + if (useExponentialBackoff) { + // Fórmula: initialDelay * (2^attempts) com limite máximo + nextDelay = Math.min(initialDelay * Math.pow(2, attempts - 1), maxDelay); + + // Adicionar jitter para evitar "thundering herd" + const jitter = nextDelay * jitterFactor * (Math.random() * 2 - 1); + nextDelay = Math.max(initialDelay, nextDelay + jitter); + } + + this.logger.log({ + local: `${origin}`, + message: `Aguardando ${nextDelay.toFixed(1)} segundos antes da próxima tentativa`, + url: baseURL, + }); + + await new Promise((resolve) => setTimeout(resolve, nextDelay * 1000)); } } } From 0597993c5db97cef899eaa0f24ba7f84e28572b1 Mon Sep 17 00:00:00 2001 From: Guilherme Jansen Date: Fri, 21 Mar 2025 14:56:45 -0300 Subject: [PATCH 4/4] =?UTF-8?q?fix(webhook):=20implementar=20timeout=20con?= =?UTF-8?q?figur=C3=A1vel=20e=20sistema=20de=20retentativas=20inteligente?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/integrations/event/webhook/webhook.controller.ts | 8 ++++---- src/config/env.config.ts | 8 +++++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/api/integrations/event/webhook/webhook.controller.ts b/src/api/integrations/event/webhook/webhook.controller.ts index b77c78295..49d858240 100644 --- a/src/api/integrations/event/webhook/webhook.controller.ts +++ b/src/api/integrations/event/webhook/webhook.controller.ts @@ -157,7 +157,7 @@ export class WebhookController extends EventController implements EventControlle try { if (isURL(globalURL)) { - const httpService = axios.create({ + const httpService = axios.create({ baseURL: globalURL, timeout: webhookConfig.REQUEST?.TIMEOUT_MS ?? 30000, }); @@ -221,10 +221,10 @@ export class WebhookController extends EventController implements EventControlle return; } catch (error) { attempts++; - + // Verificar se é um erro de timeout const isTimeout = error.code === 'ECONNABORTED'; - + // Verificar se o erro não deve gerar retry com base no status code if (error?.response?.status && nonRetryableStatusCodes.includes(error.response.status)) { this.logger.error({ @@ -261,7 +261,7 @@ export class WebhookController extends EventController implements EventControlle if (useExponentialBackoff) { // Fórmula: initialDelay * (2^attempts) com limite máximo nextDelay = Math.min(initialDelay * Math.pow(2, attempts - 1), maxDelay); - + // Adicionar jitter para evitar "thundering herd" const jitter = nextDelay * jitterFactor * (Math.random() * 2 - 1); nextDelay = Math.max(initialDelay, nextDelay + jitter); diff --git a/src/config/env.config.ts b/src/config/env.config.ts index c5b7d1917..7e58d50d1 100644 --- a/src/config/env.config.ts +++ b/src/config/env.config.ts @@ -220,8 +220,8 @@ export type CacheConfLocal = { TTL: number; }; export type SslConf = { PRIVKEY: string; FULLCHAIN: string }; -export type Webhook = { - GLOBAL?: GlobalWebhook; +export type Webhook = { + GLOBAL?: GlobalWebhook; EVENTS: EventsWebhook; REQUEST?: { TIMEOUT_MS?: number; @@ -520,7 +520,9 @@ export class ConfigService { USE_EXPONENTIAL_BACKOFF: process.env?.WEBHOOK_RETRY_USE_EXPONENTIAL_BACKOFF !== 'false', MAX_DELAY_SECONDS: Number.parseInt(process.env?.WEBHOOK_RETRY_MAX_DELAY_SECONDS) || 300, JITTER_FACTOR: Number.parseFloat(process.env?.WEBHOOK_RETRY_JITTER_FACTOR) || 0.2, - NON_RETRYABLE_STATUS_CODES: process.env?.WEBHOOK_RETRY_NON_RETRYABLE_STATUS_CODES?.split(',').map(Number) || [400, 401, 403, 404, 422], + NON_RETRYABLE_STATUS_CODES: process.env?.WEBHOOK_RETRY_NON_RETRYABLE_STATUS_CODES?.split(',').map(Number) || [ + 400, 401, 403, 404, 422, + ], }, }, CONFIG_SESSION_PHONE: {