import { loadEnv } from "../lib/env.ts"; const BASE_URL_DEFAULT = "https://services.leadconnectorhq.com"; /** * La cabecera `Version` no es opcional y no es una sola: la familia de * calendarios exige `v3` y el resto `2021-07-28`. Omitirla o equivocarla es un * 400, y es el error más fácil de cometer al reciclar código entre dominios. */ export const VERSION_DEFAULT = "2021-07-28"; export const VERSION_CALENDARS = "v3"; /** * Intervalo conservador mientras el CRM no diga su cuota real. * * MEDIDO (hallazgo 37): las cabeceras `x-ratelimit-*` declaran 100 peticiones * por 10 s, o sea 1 cada 100 ms — 6,5 veces más de lo que este valor asume. Se * mantiene como respaldo para la primera petición de un token, antes de haber * visto ninguna cabecera; a partir de ahí manda `intervaloDe()`. */ export const MIN_INTERVAL_MS = 650; /** Margen sobre la cuota declarada: no se corre al límite exacto. */ const MARGEN = 1.5; const MAX_RETRIES = 3; export interface Limites { max: number; ventanaMs: number; restantes: number; diarioRestante: number | null; } const limitesPorToken = new Map(); /** Registra lo que el CRM dice de su propia cuota. Una respuesta sin cabeceras * no borra lo ya sabido: no todas las rutas las devuelven. */ export function anotarLimites(token: string, h: Headers): void { const max = Number(h.get("x-ratelimit-max")); const ventanaMs = Number(h.get("x-ratelimit-interval-milliseconds")); if (!max || !ventanaMs) return; limitesPorToken.set(token, { max, ventanaMs, restantes: Number(h.get("x-ratelimit-remaining") ?? max), diarioRestante: h.get("x-ratelimit-daily-remaining") ? Number(h.get("x-ratelimit-daily-remaining")) : null, }); } export function limitesDe(token: string): Limites | null { return limitesPorToken.get(token) ?? null; } /** * Cuánto esperar entre peticiones de ESTE token. * * Se toma la cuota que el CRM declara, con un 50 % de margen y no al límite * exacto: el worker de la bandeja y una sincronización manual pueden coincidir. * Si la ventana está casi agotada se espacia hasta que se renueve, que sale más * barato que comerse un 429 y su espera lineal de 5, 10 y 15 s. */ export function intervaloDe(token: string): number { const l = limitesPorToken.get(token); if (!l) return MIN_INTERVAL_MS; const base = Math.ceil((l.ventanaMs / l.max) * MARGEN); if (l.restantes <= 5) { return Math.max(base, Math.ceil(l.ventanaMs / Math.max(1, l.restantes))); } return base; } export class CrmError extends Error { constructor( readonly status: number, message: string, readonly body?: unknown ) { super(`CRM ${status}: ${message}`); this.name = "CrmError"; } } /** * Un fallo de transporte no es una respuesta: el servidor no habló, así que no * se sabe si la escritura entró. Reenviarlo es fabricar la doble creación. Se * marca aparte para que la bandeja de salida lo deje en `indeterminado` y lo * resuelva **leyendo**, nunca reintentando. */ export class CrmTransportError extends Error { readonly indeterminate = true; constructor(message: string) { super(`CRM sin respuesta: ${message}`); this.name = "CrmTransportError"; } } // Un reloj por token, no uno global: el límite del CRM es por credencial, así // que un semáforo único serializaría negocios que pueden ir en paralelo. Con // diez cuentas, la décima esperaría a las nueve anteriores sin ninguna razón. const ultimaPeticionPorToken = new Map(); /** Milisegundos que este token debe esperar antes de su próxima petición. */ export function esperaDeToken(token: string, ahora = Date.now()): number { const ultima = ultimaPeticionPorToken.get(token) ?? 0; return Math.max(0, intervaloDe(token) - (ahora - ultima)); } export function registrarPeticion(token: string, ahora = Date.now()): void { ultimaPeticionPorToken.set(token, ahora); } async function throttle(token: string) { const espera = esperaDeToken(token); if (espera > 0) await new Promise((r) => setTimeout(r, espera)); registrarPeticion(token); } export interface CrmOptions { /** * Token privado de la subcuenta. **Obligatorio y sin valor por defecto.** * * Antes caía a `requireEnv("CRM_TOKEN")`, una variable global del proceso: con * dos negocios, olvidar el token no daba error — usaba el del primero contra * la subcuenta del segundo. Al hacerlo obligatorio, ese olvido pasa a ser un * error de compilación, que es el gate real de calidad de este repo. * * Sale siempre de `CrmCtx.token` (ver platform/crm/ctx.ts). */ token: string; body?: unknown; version?: string; query?: Record; } export async function crmRequest( method: string, path: string, opts: CrmOptions ): Promise { loadEnv(); const base = process.env.CRM_BASE_URL || BASE_URL_DEFAULT; const token = opts.token; let url = `${base}${path}`; if (opts.query) { const q = new URLSearchParams(); for (const [k, v] of Object.entries(opts.query)) { if (v !== undefined) q.set(k, String(v)); } const s = q.toString(); if (s) url += (url.includes("?") ? "&" : "?") + s; } const version = opts.version ?? (path.startsWith("/calendars/") ? VERSION_CALENDARS : VERSION_DEFAULT); let intento = 0; for (;;) { await throttle(token); let res: Response; try { res = await fetch(url, { method, headers: { authorization: `Bearer ${token}`, version, accept: "application/json", ...(opts.body !== undefined ? { "content-type": "application/json" } : {}), }, body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined, }); } catch (e: any) { // Timeout / conexión caída: no hubo respuesta. Se reintenta el transporte // solo en GET, que es idempotente por naturaleza; en escrituras se // propaga para que arriba se resuelva leyendo. if (method === "GET" && intento < MAX_RETRIES) { await new Promise((r) => setTimeout(r, 2000 * 2 ** intento)); intento++; continue; } throw new CrmTransportError(`${e?.name ?? "Error"}: ${e?.message ?? e}`); } // El CRM declara su propia cuota en cada respuesta. Leerla es la única // forma de no ir a ciegas: el intervalo por defecto era una estimación. anotarLimites(token, res.headers); if (res.status === 429 || res.status >= 500) { if (intento < MAX_RETRIES) { // 429 lineal (5/10/15 s), 5xx exponencial: es la política ya medida en // el proyecto hermano. const espera = res.status === 429 ? 5000 * (intento + 1) : 2000 * 2 ** intento; await new Promise((r) => setTimeout(r, espera)); intento++; continue; } } const texto = await res.text(); let cuerpo: any = null; try { cuerpo = texto ? JSON.parse(texto) : null; } catch { cuerpo = texto; } if (res.status === 401) { // Rotar el token es trabajo humano: no se reintenta y se dice claro. throw new CrmError(401, "Token rechazado — hay que regenerarlo en el CRM", cuerpo); } if (!res.ok) { const msg = (Array.isArray(cuerpo?.message) ? cuerpo.message.join("; ") : cuerpo?.message) || cuerpo?.error || res.statusText; throw new CrmError(res.status, String(msg), cuerpo); } return cuerpo as T; } }