diff --git a/docker-compose.yml b/docker-compose.yml index 2a6f97b..ae9595d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,8 @@ services: - GRAPHS_BACKFILL_MAX_TINY_BLOCK_RETRIES=${GRAPHS_BACKFILL_MAX_TINY_BLOCK_RETRIES} - TINY_FETCH_SELLER_DETAILS=${TINY_FETCH_SELLER_DETAILS} - TINY_ORDER_DETAILS_CACHE_TTL_MS=${TINY_ORDER_DETAILS_CACHE_TTL_MS} + - TINY_LIVE_REQUEST_DELAY_MS=${TINY_LIVE_REQUEST_DELAY_MS} + - TINY_LIVE_MAX_QUEUE_WAIT_MS=${TINY_LIVE_MAX_QUEUE_WAIT_MS} # Optional: If you use a shared Docker network for Nginx Proxy Manager, you can attach it here: # networks: # - nginx-proxy-manager-network diff --git a/src/controllers/webhook.controller.ts b/src/controllers/webhook.controller.ts index 5ea6120..a33fe03 100644 --- a/src/controllers/webhook.controller.ts +++ b/src/controllers/webhook.controller.ts @@ -18,6 +18,23 @@ const orderDetailsCache = new Map(); const inFlightOrderDetails = new Map>(); const configuredCacheTtlMs = Number(process.env.TINY_ORDER_DETAILS_CACHE_TTL_MS || 120000); const ORDER_DETAILS_CACHE_TTL_MS = Number.isFinite(configuredCacheTtlMs) ? configuredCacheTtlMs : 120000; +const numberEnv = (name: string, fallback: number) => { + const value = Number(process.env[name] ?? fallback); + return Number.isFinite(value) && value >= 0 ? value : fallback; +}; +const LIVE_TINY_REQUEST_DELAY_MS = numberEnv('TINY_LIVE_REQUEST_DELAY_MS', 6000); +const LIVE_TINY_MAX_QUEUE_WAIT_MS = numberEnv('TINY_LIVE_MAX_QUEUE_WAIT_MS', 30000); +let liveTinyQueueTail: Promise = Promise.resolve(); +let liveTinyNextRequestAt = 0; +let liveTinyPendingRequests = 0; + +class LiveTinyQueueBacklogError extends Error { + constructor(orderId: string, estimatedWaitMs: number) { + super(`Tiny live queue is too busy for Order ID ${orderId}. Estimated wait ${estimatedWaitMs}ms.`); + } +} + +const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); function pruneExpiredOrderDetailsCache() { const now = Date.now(); @@ -50,6 +67,42 @@ function setCachedOrderDetails(orderId: string, cached: Omit(orderId: string, servicePhp: string, operation: () => Promise): Promise { + if (LIVE_TINY_REQUEST_DELAY_MS <= 0) { + return operation(); + } + + const estimatedWaitMs = Math.max(0, liveTinyNextRequestAt - Date.now()) + (liveTinyPendingRequests * LIVE_TINY_REQUEST_DELAY_MS); + if (estimatedWaitMs > LIVE_TINY_MAX_QUEUE_WAIT_MS) { + throw new LiveTinyQueueBacklogError(orderId, estimatedWaitMs); + } + + liveTinyPendingRequests += 1; + const previousTail = liveTinyQueueTail.catch(() => undefined); + const runPromise = previousTail.then(async () => { + const waitMs = Math.max(0, liveTinyNextRequestAt - Date.now()); + if (waitMs > 0) { + console.log(`[Tiny Live Queue] Waiting ${waitMs}ms before ${servicePhp} for Order ID: ${orderId}.`); + await sleep(waitMs); + } + + liveTinyNextRequestAt = Date.now() + LIVE_TINY_REQUEST_DELAY_MS; + console.log(`[Tiny Live Queue] Fetching ${servicePhp} for Order ID: ${orderId}. Pending: ${liveTinyPendingRequests}.`); + return operation(); + }).finally(() => { + liveTinyPendingRequests = Math.max(0, liveTinyPendingRequests - 1); + }); + + liveTinyQueueTail = runPromise.then(() => undefined, () => undefined); + return runPromise; +} + +async function tinyLivePost(orderId: string, servicePhp: string, params: URLSearchParams) { + return runWithLiveTinySlot(orderId, servicePhp, () => axios.post(`https://api.tiny.com.br/api2/${servicePhp}`, params, { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' } + })); +} + async function fetchOrderDetailsFromTiny(orderId: string, tinyApiToken: string): Promise { console.log(`Fetching full details for Order ID: ${orderId} from Tiny API...`); const params = new URLSearchParams(); @@ -57,9 +110,7 @@ async function fetchOrderDetailsFromTiny(orderId: string, tinyApiToken: string): params.append('id', orderId); params.append('formato', 'JSON'); - const apiResponse = await axios.post('https://api.tiny.com.br/api2/pedido.obter.php', params, { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' } - }); + const apiResponse = await tinyLivePost(orderId, 'pedido.obter.php', params); if (apiResponse.data?.retorno?.status !== 'OK') { console.error('Tiny API returned an error:', apiResponse.data?.retorno?.erros || 'Unknown error'); @@ -80,9 +131,7 @@ async function fetchOrderDetailsFromTiny(orderId: string, tinyApiToken: string): vendorParams.append('id', idVendedor); vendorParams.append('formato', 'JSON'); - const vendorResponse = await axios.post('https://api.tiny.com.br/api2/contato.obter.php', vendorParams, { - headers: { 'Content-Type': 'application/x-www-form-urlencoded' } - }); + const vendorResponse = await tinyLivePost(orderId, 'contato.obter.php', vendorParams); if (vendorResponse.data?.retorno?.status === 'OK') { const contato = vendorResponse.data.retorno.contato; @@ -178,7 +227,11 @@ export const handleTinyOrderUpdate = async (req: Request, res: Response): Promis whatsappVendedor = details.whatsappVendedor; } } catch (apiError: any) { - console.error('Failed to fetch from Tiny API:', apiError.message); + if (apiError instanceof LiveTinyQueueBacklogError) { + console.warn(`${apiError.message} Forwarding without Tiny enrichment.`); + } else { + console.error('Failed to fetch from Tiny API:', apiError.message); + } } } else { console.warn('TINY_API_TOKEN is not set in environment variables. Skipping full details fetch.');