Some checks are pending
QA Gate (P0/P1) / Test affected app (pull_request) Waiting to run
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>
230 lines
8.3 KiB
TypeScript
230 lines
8.3 KiB
TypeScript
/**
|
||
* 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);
|
||
}
|
||
}
|