Files
tiny-webhook/src/services/tiny-api.service.ts
2026-06-22 16:27:23 -03:00

169 lines
5.3 KiB
TypeScript

import axios from 'axios';
import fs from 'fs';
import os from 'os';
import path from 'path';
const DEFAULT_LOCK_DIR = path.join(os.tmpdir(), 'api-tiny-n8n-rate-limit.lock');
const DEFAULT_STATE_FILE = path.join(os.tmpdir(), 'api-tiny-n8n-rate-limit.json');
const LOCK_STALE_MS = 30000;
const DEFAULT_BLOCK_RETRY_DELAY_MS = 120000;
const DEFAULT_BLOCK_MAX_RETRIES = 30;
type TinyRateLimitState = {
lastRequestAt?: number;
blockedUntil?: number;
};
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));
const getPositiveNumberEnv = (name: string, fallback: number) => {
const configured = Number(process.env[name] || fallback);
return Number.isFinite(configured) && configured > 0 ? configured : 0;
};
const getMinIntervalMs = () => getPositiveNumberEnv('TINY_MIN_REQUEST_INTERVAL_MS', 0);
const getBlockRetryDelayMs = () => getPositiveNumberEnv('TINY_BLOCK_RETRY_DELAY_MS', DEFAULT_BLOCK_RETRY_DELAY_MS);
const getBlockMaxRetries = () => getPositiveNumberEnv('TINY_BLOCK_MAX_RETRIES', DEFAULT_BLOCK_MAX_RETRIES);
const getLockDir = () => process.env.TINY_RATE_LIMIT_LOCK_DIR || DEFAULT_LOCK_DIR;
const getStateFile = () => process.env.TINY_RATE_LIMIT_STATE_FILE || DEFAULT_STATE_FILE;
const readState = (): TinyRateLimitState => {
try {
const parsed = JSON.parse(fs.readFileSync(getStateFile(), 'utf-8'));
return {
lastRequestAt: Number(parsed.lastRequestAt || 0),
blockedUntil: Number(parsed.blockedUntil || 0)
};
} catch {
return {};
}
};
const writeState = (state: TinyRateLimitState) => {
fs.writeFileSync(getStateFile(), JSON.stringify(state, null, 2));
};
const removeStaleLock = (lockDir: string) => {
try {
const stats = fs.statSync(lockDir);
if (Date.now() - stats.mtimeMs > LOCK_STALE_MS) {
fs.rmSync(lockDir, { recursive: true, force: true });
}
} catch {
// Lock does not exist or cannot be inspected. The acquire loop will retry.
}
};
const acquireLock = async () => {
const lockDir = getLockDir();
while (true) {
try {
fs.mkdirSync(lockDir);
return lockDir;
} catch (error: any) {
if (error?.code !== 'EEXIST') throw error;
removeStaleLock(lockDir);
await sleep(100);
}
}
};
const releaseLock = (lockDir: string) => {
fs.rmSync(lockDir, { recursive: true, force: true });
};
const waitForTinySlot = async () => {
const minIntervalMs = getMinIntervalMs();
while (true) {
const lockDir = await acquireLock();
let waitMs = 0;
let reason = 'next Tiny API request';
try {
const state = readState();
const now = Date.now();
const lastRequestAt = Number(state.lastRequestAt || 0);
const blockedUntil = Number(state.blockedUntil || 0);
const intervalWaitMs = minIntervalMs ? Math.max(0, minIntervalMs - (now - lastRequestAt)) : 0;
const blockWaitMs = Math.max(0, blockedUntil - now);
if (blockWaitMs > intervalWaitMs) {
waitMs = blockWaitMs;
reason = 'Tiny API block cooldown';
} else {
waitMs = intervalWaitMs;
}
if (!waitMs) {
writeState({
...state,
lastRequestAt: now,
blockedUntil: blockWaitMs ? blockedUntil : 0
});
return;
}
} finally {
releaseLock(lockDir);
}
console.log(`[Tiny Rate Limit] Waiting ${waitMs}ms before ${reason}.`);
await sleep(waitMs);
}
};
const pauseAllTinyRequests = async (delayMs: number) => {
const lockDir = await acquireLock();
try {
const state = readState();
writeState({
...state,
blockedUntil: Math.max(Number(state.blockedUntil || 0), Date.now() + delayMs)
});
} finally {
releaseLock(lockDir);
}
};
const getTinyErrors = (data: any) => {
const erros = data?.retorno?.erros;
if (!erros) return [];
return Array.isArray(erros) ? erros : [erros];
};
const isTinyApiBlocked = (data: any) => {
return getTinyErrors(data).some((entry: any) => {
const message = String(entry?.erro || entry || '').toLowerCase();
return message.includes('api bloqueada') || message.includes('excedido o número de acessos');
});
};
export const tinyPost = async (servicePhp: string, params: URLSearchParams) => {
let attempt = 0;
while (true) {
await waitForTinySlot();
const response = await axios.post(`https://api.tiny.com.br/api2/${servicePhp}`, params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
if (!isTinyApiBlocked(response.data)) {
return response;
}
attempt += 1;
const maxRetries = getBlockMaxRetries();
if (attempt > maxRetries) {
return response;
}
const delayMs = getBlockRetryDelayMs();
await pauseAllTinyRequests(delayMs);
console.warn(`[Tiny Rate Limit] Tiny blocked ${servicePhp}. Pausing all Tiny API requests for ${delayMs}ms before retry ${attempt}/${maxRetries}.`);
await sleep(delayMs);
}
};