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 diff --git a/src/api/integrations/event/webhook/webhook.controller.ts b/src/api/integrations/event/webhook/webhook.controller.ts index ce709c3d4..49d858240 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<string, string> | 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<void> { + // Obter configurações de retry das variáveis de ambiente + const webhookConfig = configService.get<Webhook>('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) { @@ -209,12 +222,29 @@ export class WebhookController extends EventController implements EventControlle } 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)); } } } diff --git a/src/config/env.config.ts b/src/config/env.config.ts index 78ca891cd..7e58d50d1 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,19 @@ 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',