Skip to content

Commit 81a7ff8

Browse files
fix(webhook): implementar timeout configurável e sistema de retentativas inteligente
Resolve #1325 - Adiciona configuração de timeout via variáveis de ambiente - Implementa backoff exponencial com jitter para retentativas - Detecta erros permanentes para evitar retentativas desnecessárias - Corrige bug de duplicação de webhooks - Melhora logs para diagnóstico
1 parent b89f114 commit 81a7ff8

File tree

3 files changed

+84
-8
lines changed

3 files changed

+84
-8
lines changed

.env.example

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,16 @@ WEBHOOK_EVENTS_TYPEBOT_CHANGE_STATUS=false
173173
WEBHOOK_EVENTS_ERRORS=false
174174
WEBHOOK_EVENTS_ERRORS_WEBHOOK=
175175

176+
# Webhook timeout and retry configuration
177+
WEBHOOK_REQUEST_TIMEOUT_MS=60000
178+
WEBHOOK_RETRY_MAX_ATTEMPTS=10
179+
WEBHOOK_RETRY_INITIAL_DELAY_SECONDS=5
180+
WEBHOOK_RETRY_USE_EXPONENTIAL_BACKOFF=true
181+
WEBHOOK_RETRY_MAX_DELAY_SECONDS=300
182+
WEBHOOK_RETRY_JITTER_FACTOR=0.2
183+
# Comma separated list of HTTP status codes that should not trigger retries
184+
WEBHOOK_RETRY_NON_RETRYABLE_STATUS_CODES=400,401,403,404,422
185+
176186
# Name that will be displayed on smartphone connection
177187
CONFIG_SESSION_PHONE_CLIENT=Evolution API
178188
# Browser Name = Chrome | Firefox | Edge | Opera | Safari

src/api/integrations/event/webhook/webhook.controller.ts

Lines changed: 48 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ export class WebhookController extends EventController implements EventControlle
115115
const httpService = axios.create({
116116
baseURL,
117117
headers: webhookHeaders as Record<string, string> | undefined,
118+
timeout: webhookConfig.REQUEST?.TIMEOUT_MS ?? 30000,
118119
});
119120

120121
await this.retryWebhookRequest(httpService, webhookData, `${origin}.sendData-Webhook`, baseURL, serverUrl);
@@ -156,7 +157,10 @@ export class WebhookController extends EventController implements EventControlle
156157

157158
try {
158159
if (regex.test(globalURL)) {
159-
const httpService = axios.create({ baseURL: globalURL });
160+
const httpService = axios.create({
161+
baseURL: globalURL,
162+
timeout: webhookConfig.REQUEST?.TIMEOUT_MS ?? 30000,
163+
});
160164

161165
await this.retryWebhookRequest(
162166
httpService,
@@ -190,12 +194,20 @@ export class WebhookController extends EventController implements EventControlle
190194
origin: string,
191195
baseURL: string,
192196
serverUrl: string,
193-
maxRetries = 10,
194-
delaySeconds = 30,
197+
maxRetries?: number,
198+
delaySeconds?: number,
195199
): Promise<void> {
200+
const webhookConfig = configService.get<Webhook>('WEBHOOK');
201+
const maxRetryAttempts = maxRetries ?? webhookConfig.RETRY?.MAX_ATTEMPTS ?? 10;
202+
const initialDelay = delaySeconds ?? webhookConfig.RETRY?.INITIAL_DELAY_SECONDS ?? 5;
203+
const useExponentialBackoff = webhookConfig.RETRY?.USE_EXPONENTIAL_BACKOFF ?? true;
204+
const maxDelay = webhookConfig.RETRY?.MAX_DELAY_SECONDS ?? 300;
205+
const jitterFactor = webhookConfig.RETRY?.JITTER_FACTOR ?? 0.2;
206+
const nonRetryableStatusCodes = webhookConfig.RETRY?.NON_RETRYABLE_STATUS_CODES ?? [400, 401, 403, 404, 422];
207+
196208
let attempts = 0;
197209

198-
while (attempts < maxRetries) {
210+
while (attempts < maxRetryAttempts) {
199211
try {
200212
await httpService.post('', webhookData);
201213
if (attempts > 0) {
@@ -208,25 +220,54 @@ export class WebhookController extends EventController implements EventControlle
208220
return;
209221
} catch (error) {
210222
attempts++;
223+
224+
const isTimeout = error.code === 'ECONNABORTED';
225+
226+
if (error?.response?.status && nonRetryableStatusCodes.includes(error.response.status)) {
227+
this.logger.error({
228+
local: `${origin}`,
229+
message: `Erro não recuperável (${error.response.status}): ${error?.message}. Cancelando retentativas.`,
230+
statusCode: error?.response?.status,
231+
url: baseURL,
232+
server_url: serverUrl,
233+
});
234+
throw error;
235+
}
211236

212237
this.logger.error({
213238
local: `${origin}`,
214-
message: `Tentativa ${attempts}/${maxRetries} falhou: ${error?.message}`,
239+
message: `Tentativa ${attempts}/${maxRetryAttempts} falhou: ${isTimeout ? 'Timeout da requisição' : error?.message}`,
215240
hostName: error?.hostname,
216241
syscall: error?.syscall,
217242
code: error?.code,
243+
isTimeout,
244+
statusCode: error?.response?.status,
218245
error: error?.errno,
219246
stack: error?.stack,
220247
name: error?.name,
221248
url: baseURL,
222249
server_url: serverUrl,
223250
});
224251

225-
if (attempts === maxRetries) {
252+
if (attempts === maxRetryAttempts) {
226253
throw error;
227254
}
228255

229-
await new Promise((resolve) => setTimeout(resolve, delaySeconds * 1000));
256+
let nextDelay = initialDelay;
257+
if (useExponentialBackoff) {
258+
nextDelay = Math.min(initialDelay * Math.pow(2, attempts - 1), maxDelay);
259+
260+
const jitter = nextDelay * jitterFactor * (Math.random() * 2 - 1);
261+
nextDelay = Math.max(initialDelay, nextDelay + jitter);
262+
}
263+
264+
this.logger.log({
265+
local: `${origin}`,
266+
message: `Aguardando ${nextDelay.toFixed(1)} segundos antes da próxima tentativa`,
267+
url: baseURL,
268+
});
269+
270+
await new Promise((resolve) => setTimeout(resolve, nextDelay * 1000));
230271
}
231272
}
232273
}

src/config/env.config.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,21 @@ export type CacheConfLocal = {
229229
TTL: number;
230230
};
231231
export type SslConf = { PRIVKEY: string; FULLCHAIN: string };
232-
export type Webhook = { GLOBAL?: GlobalWebhook; EVENTS: EventsWebhook };
232+
export type Webhook = {
233+
GLOBAL?: GlobalWebhook;
234+
EVENTS: EventsWebhook;
235+
REQUEST?: {
236+
TIMEOUT_MS?: number;
237+
};
238+
RETRY?: {
239+
MAX_ATTEMPTS?: number;
240+
INITIAL_DELAY_SECONDS?: number;
241+
USE_EXPONENTIAL_BACKOFF?: boolean;
242+
MAX_DELAY_SECONDS?: number;
243+
JITTER_FACTOR?: number;
244+
NON_RETRYABLE_STATUS_CODES?: number[];
245+
};
246+
};
233247
export type Pusher = { ENABLED: boolean; GLOBAL?: GlobalPusher; EVENTS: EventsPusher };
234248
export type ConfigSessionPhone = { CLIENT: string; NAME: string; VERSION: string };
235249
export type QrCode = { LIMIT: number; COLOR: string };
@@ -543,6 +557,17 @@ export class ConfigService {
543557
ERRORS: process.env?.WEBHOOK_EVENTS_ERRORS === 'true',
544558
ERRORS_WEBHOOK: process.env?.WEBHOOK_EVENTS_ERRORS_WEBHOOK || '',
545559
},
560+
REQUEST: {
561+
TIMEOUT_MS: Number.parseInt(process.env?.WEBHOOK_REQUEST_TIMEOUT_MS) || 30000,
562+
},
563+
RETRY: {
564+
MAX_ATTEMPTS: Number.parseInt(process.env?.WEBHOOK_RETRY_MAX_ATTEMPTS) || 10,
565+
INITIAL_DELAY_SECONDS: Number.parseInt(process.env?.WEBHOOK_RETRY_INITIAL_DELAY_SECONDS) || 5,
566+
USE_EXPONENTIAL_BACKOFF: process.env?.WEBHOOK_RETRY_USE_EXPONENTIAL_BACKOFF !== 'false',
567+
MAX_DELAY_SECONDS: Number.parseInt(process.env?.WEBHOOK_RETRY_MAX_DELAY_SECONDS) || 300,
568+
JITTER_FACTOR: Number.parseFloat(process.env?.WEBHOOK_RETRY_JITTER_FACTOR) || 0.2,
569+
NON_RETRYABLE_STATUS_CODES: process.env?.WEBHOOK_RETRY_NON_RETRYABLE_STATUS_CODES?.split(',').map(Number) || [400, 401, 403, 404, 422],
570+
},
546571
},
547572
CONFIG_SESSION_PHONE: {
548573
CLIENT: process.env?.CONFIG_SESSION_PHONE_CLIENT || 'Evolution API',

0 commit comments

Comments
 (0)