Files
sase.tr/apps/api/src/integrations/carcatonline/carcatonline.pacing.ts
Semih Yesilyurt 337192685c
Some checks are pending
QA Gate (P0/P1) / Test affected app (pull_request) Waiting to run
feat(carcatonline): saatlik istek kotası + kaldığı yerden devam eden tarama
Dev'de ölçüldü: upstream 429 gövdesi "Customer Hourly Request Limit Exceeded",
~110 çağrı sonra; 47 dk sonraki denemede hâlâ 429 (kayan saatlik kota, sabit
kilit değil). Kilide çarpıp her araç için baştan başlamak yerine:
- Throttle: `CARCATONLINE_HOURLY_CALL_CAP` (varsayılan 90) — Istanbul saat
  dilimi başına Redis sayaç; dolunca iş kendini bir sonraki saat başına erteler.
- Tarama cache'i: groups2 cevapları Redis'te 24 s (`carcatonline:groups:*`);
  tekrar koşan iş cache'ten okur, çağrı harcamaz.
- Varyant çözümü metadata'ya `status:"matched"` olarak yazılır; tarama saat
  ortasında kesilirse sonraki koşu kademeli seçimi atlar. Scan 'matched'
  satırları 7 günlük bekleme olmadan yeniden kuyruklar.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-26 16:25:00 +03:00

230 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Shared throttling primitives for carcatonline (used by the worker backfill and
* the API's on-demand parts fetch). All state lives in Redis so the API and the
* worker share one budget:
* - min interval between calls (measured 2026-09-26: ~110 calls at 1/2.2 s → 429 + ~40 min lockout;
* the quota looks like ~100 calls per window, so the default is 1 call / 7 s ≈ 8.5/min)
* - lockout flag set on any 429
* - per-Istanbul-day call counter with a hard cap
* - the night window (default 20:00–07:00 Europe/Istanbul) for bulk work
*/
import type { CarcatonlineToken } from "./carcatonline.client";
export interface RedisLike {
get(key: string): Promise<string | null>;
set(key: string, value: string, mode: "EX", ttlSeconds: number): Promise<unknown>;
del(key: string): Promise<unknown>;
incr(key: string): Promise<number>;
expire(key: string, seconds: number): Promise<unknown>;
/** SET key value PX ms NX — returns "OK" or null. */
set(key: string, value: string, mode: "PX", ttlMs: number, cond: "NX"): Promise<unknown>;
}
export const CARCAT_KEYS = {
token: "carcatonline:token",
lockout: "carcatonline:lockout",
lastCall: "carcatonline:last-call",
callsPrefix: "carcatonline:calls:",
hourlyPrefix: "carcatonline:calls-hour:",
groupsPrefix: "carcatonline:groups:",
modelsPrefix: "carcatonline:models:",
} as const;
export interface CarcatConfig {
minIntervalMs: number;
dailyCallCap: number;
/** Upstream "Customer Hourly Request Limit" (measured ~100/h) — stay under it instead of eating a lockout. */
hourlyCallCap: number;
windowStartHour: number;
windowEndHour: number;
lockoutSeconds: number;
}
export function carcatConfigFromEnv(env: NodeJS.ProcessEnv = process.env): CarcatConfig {
const num = (v: string | undefined, d: number): number => {
const n = Number(v);
return Number.isFinite(n) && n >= 0 ? n : d;
};
return {
minIntervalMs: num(env.CARCATONLINE_MIN_INTERVAL_MS, 7000),
dailyCallCap: num(env.CARCATONLINE_DAILY_CALL_CAP, 15000),
hourlyCallCap: num(env.CARCATONLINE_HOURLY_CALL_CAP, 90),
windowStartHour: num(env.CARCATONLINE_WINDOW_START, 20),
windowEndHour: num(env.CARCATONLINE_WINDOW_END, 7),
lockoutSeconds: num(env.CARCATONLINE_LOCKOUT_SECONDS, 40 * 60),
};
}
const ISTANBUL_OFFSET_MS = 3 * 60 * 60 * 1000; // fixed UTC+3 (no DST since 2016)
/** Hour (0-23) and "YYYY-MM-DD" in Europe/Istanbul. */
export function istanbulParts(date: Date): { hour: number; minute: number; day: string } {
const shifted = new Date(date.getTime() + ISTANBUL_OFFSET_MS);
return {
hour: shifted.getUTCHours(),
minute: shifted.getUTCMinutes(),
day: shifted.toISOString().slice(0, 10),
};
}
/** True when `date` falls inside the bulk window. Wraps past midnight (20 → 7). */
export function isWithinWindow(date: Date, startHour: number, endHour: number): boolean {
const { hour } = istanbulParts(date);
if (startHour === endHour) return true;
if (startHour < endHour) return hour >= startHour && hour < endHour;
return hour >= startHour || hour < endHour;
}
/** Milliseconds until the next window opens (0 when already open). */
export function msUntilWindowOpens(date: Date, startHour: number, endHour: number): number {
if (isWithinWindow(date, startHour, endHour)) return 0;
const shifted = new Date(date.getTime() + ISTANBUL_OFFSET_MS);
const next = new Date(shifted);
next.setUTCHours(startHour, 0, 0, 0);
if (next.getTime() <= shifted.getTime()) next.setUTCDate(next.getUTCDate() + 1);
return next.getTime() - shifted.getTime();
}
export function dailyCallsKey(date: Date): string {
return `${CARCAT_KEYS.callsPrefix}${istanbulParts(date).day}`;
}
/** Clock-hour bucket in Istanbul time ("2026-09-26T21"). */
export function hourlyCallsKey(date: Date): string {
const { day, hour } = istanbulParts(date);
return `${CARCAT_KEYS.hourlyPrefix}${day}T${String(hour).padStart(2, "0")}`;
}
/** Milliseconds until the next clock hour starts (plus a small margin). */
export function msUntilNextHour(date: Date): number {
const ms = date.getTime();
return 3600_000 - (ms % 3600_000) + 30_000;
}
export class CarcatonlineLockedError extends Error {
constructor(readonly retryAfterMs: number) {
super(`carcatonline is locked out for ${Math.round(retryAfterMs / 1000)}s`);
this.name = "CarcatonlineLockedError";
}
}
export class CarcatonlineBudgetError extends Error {
constructor(
readonly used: number,
readonly cap: number,
) {
super(`carcatonline daily call cap reached (${used}/${cap})`);
this.name = "CarcatonlineBudgetError";
}
}
export class CarcatonlineHourlyCapError extends Error {
constructor(
readonly used: number,
readonly cap: number,
readonly retryAfterMs: number,
) {
super(
`carcatonline hourly call cap reached (${used}/${cap}); next hour in ${Math.round(retryAfterMs / 60000)} min`,
);
this.name = "CarcatonlineHourlyCapError";
}
}
export class CarcatonlineWindowClosedError extends Error {
constructor(readonly retryAfterMs: number) {
super(`carcatonline bulk window closed; reopens in ${Math.round(retryAfterMs / 60000)} min`);
this.name = "CarcatonlineWindowClosedError";
}
}
/** Redis-backed token store shared by API and worker. */
export function redisTokenStore(redis: RedisLike) {
return {
async get(): Promise<CarcatonlineToken | null> {
const raw = await redis.get(CARCAT_KEYS.token);
if (!raw) return null;
try {
const t = JSON.parse(raw) as CarcatonlineToken;
return t.token && t.expiresAt ? t : null;
} catch {
return null;
}
},
async set(t: CarcatonlineToken): Promise<void> {
const ttl = Math.max(60, Math.floor((t.expiresAt - Date.now()) / 1000));
await redis.set(CARCAT_KEYS.token, JSON.stringify(t), "EX", ttl);
},
async clear(): Promise<void> {
await redis.del(CARCAT_KEYS.token);
},
};
}
/**
* Coordinates every outbound carcatonline call. `beforeCall()` must be awaited
* before each request: it throws when locked out or over the daily cap, and
* otherwise sleeps until the shared min-interval slot is free and counts the call.
*/
export class CarcatonlineThrottle {
constructor(
private readonly redis: RedisLike,
private readonly cfg: CarcatConfig,
private readonly now: () => Date = () => new Date(),
private readonly sleep: (ms: number) => Promise<void> = (ms) =>
new Promise((r) => setTimeout(r, ms)),
) {}
async lockoutRemainingMs(): Promise<number> {
const until = Number((await this.redis.get(CARCAT_KEYS.lockout)) ?? 0);
return until > this.now().getTime() ? until - this.now().getTime() : 0;
}
async markLockout(): Promise<void> {
const until = this.now().getTime() + this.cfg.lockoutSeconds * 1000;
await this.redis.set(CARCAT_KEYS.lockout, String(until), "EX", this.cfg.lockoutSeconds + 60);
}
async callsToday(): Promise<number> {
return Number((await this.redis.get(dailyCallsKey(this.now()))) ?? 0);
}
async callsThisHour(): Promise<number> {
return Number((await this.redis.get(hourlyCallsKey(this.now()))) ?? 0);
}
async beforeCall(): Promise<void> {
const locked = await this.lockoutRemainingMs();
if (locked > 0) throw new CarcatonlineLockedError(locked);
const used = await this.callsToday();
if (used >= this.cfg.dailyCallCap)
throw new CarcatonlineBudgetError(used, this.cfg.dailyCallCap);
const usedHour = await this.callsThisHour();
if (usedHour >= this.cfg.hourlyCallCap) {
throw new CarcatonlineHourlyCapError(
usedHour,
this.cfg.hourlyCallCap,
msUntilNextHour(this.now()),
);
}
// Shared min-interval slot: SET NX PX; spin (bounded) until acquired.
for (let i = 0; i < 50; i += 1) {
const ok = await this.redis.set(
CARCAT_KEYS.lastCall,
"1",
"PX",
this.cfg.minIntervalMs,
"NX",
);
if (ok === "OK") break;
await this.sleep(Math.max(100, Math.floor(this.cfg.minIntervalMs / 4)));
}
const key = dailyCallsKey(this.now());
const n = await this.redis.incr(key);
if (n === 1) await this.redis.expire(key, 3 * 24 * 3600);
const hourKey = hourlyCallsKey(this.now());
const h = await this.redis.incr(hourKey);
if (h === 1) await this.redis.expire(hourKey, 2 * 3600);
}
}