feat(carcatonline): saatlik istek kotası + kaldığı yerden devam eden tarama
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>
This commit is contained in:
2026-09-26 16:25:00 +03:00
parent 125d8552de
commit 337192685c
7 changed files with 203 additions and 7 deletions

View File

@@ -2,12 +2,15 @@ import { describe, expect, it, vi } from "vitest";
import {
CARCAT_KEYS,
CarcatonlineBudgetError,
CarcatonlineHourlyCapError,
CarcatonlineLockedError,
CarcatonlineThrottle,
type RedisLike,
carcatConfigFromEnv,
dailyCallsKey,
hourlyCallsKey,
isWithinWindow,
msUntilNextHour,
msUntilWindowOpens,
redisTokenStore,
} from "./carcatonline.pacing";
@@ -80,6 +83,7 @@ describe("CarcatonlineThrottle", () => {
const cfg = {
minIntervalMs: 2000,
dailyCallCap: 3,
hourlyCallCap: 1000,
windowStartHour: 20,
windowEndHour: 7,
lockoutSeconds: 2400,
@@ -120,6 +124,42 @@ describe("CarcatonlineThrottle", () => {
});
});
describe("hourly request cap", () => {
const base = {
minIntervalMs: 2000,
dailyCallCap: 1000,
hourlyCallCap: 2,
windowStartHour: 20,
windowEndHour: 7,
lockoutSeconds: 2400,
};
it("keys calls by Istanbul clock hour and computes the wait to the next hour", () => {
expect(hourlyCallsKey(at("2026-09-26T19:40:00Z"))).toBe(
"carcatonline:calls-hour:2026-09-26T22",
);
expect(msUntilNextHour(at("2026-09-26T19:40:00Z"))).toBe(20 * 60_000 + 30_000);
});
it("stops before the upstream hourly limit and reports the wait", async () => {
const redis = fakeRedis();
const t = at("2026-09-26T19:40:00Z");
const throttle = new CarcatonlineThrottle(
redis,
base,
() => t,
async () => {
redis.store.delete(CARCAT_KEYS.lastCall);
},
);
await throttle.beforeCall();
redis.store.delete(CARCAT_KEYS.lastCall);
await throttle.beforeCall();
expect(await throttle.callsThisHour()).toBe(2);
await expect(throttle.beforeCall()).rejects.toBeInstanceOf(CarcatonlineHourlyCapError);
});
});
describe("redisTokenStore", () => {
it("stores the token with a TTL derived from its expiry", async () => {
const redis = fakeRedis();

View File

@@ -25,12 +25,16 @@ export const CARCAT_KEYS = {
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;
@@ -44,6 +48,7 @@ export function carcatConfigFromEnv(env: NodeJS.ProcessEnv = process.env): Carca
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),
@@ -84,6 +89,18 @@ 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`);
@@ -101,6 +118,19 @@ export class CarcatonlineBudgetError extends Error {
}
}
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`);
@@ -159,12 +189,24 @@ export class CarcatonlineThrottle {
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(
@@ -180,5 +222,8 @@ export class CarcatonlineThrottle {
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);
}
}

View File

@@ -19,6 +19,11 @@ export interface CrawlHooks {
beforeCall(): Promise<void>;
/** Throw to abort (e.g. bulk window closed). */
checkAbort?(): void;
/** Optional response cache so a re-run after a quota stop resumes without re-spending calls. */
cache?: {
get(key: string): Promise<CarcatGroup[] | null>;
set(key: string, groups: CarcatGroup[]): Promise<void>;
};
}
export interface SelectedParameter {
@@ -132,9 +137,14 @@ export async function crawlGroupTree(
while (queue.length > 0 && nodes.length < maxNodes) {
const { groupId, depth } = queue.shift() as { groupId: string | null; depth: number };
hooks.checkAbort?.();
await hooks.beforeCall();
const groups = await client.groups(catalogId, carId, groupId ?? undefined);
calls += 1;
const cacheKey = `${catalogId}:${carId}:${groupId ?? "root"}`;
let groups = (await hooks.cache?.get(cacheKey)) ?? null;
if (!groups) {
await hooks.beforeCall();
groups = await client.groups(catalogId, carId, groupId ?? undefined);
calls += 1;
await hooks.cache?.set(cacheKey, groups);
}
for (const g of groups) {
if (!g?.id || seen.has(g.id)) continue;
seen.add(g.id);

View File

@@ -11,6 +11,7 @@ const DAY = new Date("2026-09-26T09:00:00Z"); // 12:00 Istanbul
const CFG = {
minIntervalMs: 0,
dailyCallCap: 1000,
hourlyCallCap: 1000,
windowStartHour: 20,
windowEndHour: 7,
lockoutSeconds: 2400,
@@ -255,6 +256,58 @@ describe("processCarcatonlineBackfill", () => {
expect(j.moveToDelayed.mock.calls[0][0]).toBeGreaterThan(NIGHT.getTime() + 2400 * 1000);
});
it("vehicle: stops at the hourly cap and re-schedules to the next clock hour", async () => {
const { db } = fakeDb([[cv], []]);
const redis = fakeRedis();
const client = fakeClient({
root: [{ id: "g1", name: "Engine", hasSubgroups: false, hasParts: true }],
});
const j = job(CARCAT_JOB.vehicle, { catalogVehicleId: "cv-1" });
const deps = { ...baseDeps(db, redis, client, NIGHT), config: { ...CFG, hourlyCallCap: 1 } };
await expect(processCarcatonlineBackfill(j, "tok", deps)).rejects.toBeInstanceOf(DelayedError);
// models (1 call) fits the cap; the cascade's first call trips it → delayed to the next hour + margin
const [ts] = j.moveToDelayed.mock.calls[0];
expect(ts).toBe(Date.parse("2026-09-26T20:00:00Z") + 30_000);
});
it("vehicle: a re-run resumes from the matched car and cached group responses without new calls", async () => {
const tree: Record<string, CarcatGroup[]> = {
root: [{ id: "g1", name: "Engine", hasSubgroups: true, hasParts: false }],
g1: [{ id: "g1a", name: "Oil", hasSubgroups: false, hasParts: true }],
};
const matched = {
...cv,
metadata: {
carcatonline: {
status: "matched",
catalogId: "pl_renault",
modelId: "m-kadjar",
carId: "car-1",
parameters: [{ key: "engine", value: "1.5", idx: "e15" }],
at: "2026-09-26T19:00:00Z",
},
},
};
const { db, inserts } = fakeDb([[matched], []]);
const redis = fakeRedis();
redis.store.set(
`${CARCAT_KEYS.modelsPrefix}pl_renault`,
JSON.stringify([{ id: "m-kadjar", name: "KADJAR" }]),
);
redis.store.set(`${CARCAT_KEYS.groupsPrefix}pl_renault:car-1:root`, JSON.stringify(tree.root));
redis.store.set(`${CARCAT_KEYS.groupsPrefix}pl_renault:car-1:g1`, JSON.stringify(tree.g1));
const client = fakeClient({});
const r = await processCarcatonlineBackfill(
job(CARCAT_JOB.vehicle, { catalogVehicleId: "cv-1" }),
"tok",
baseDeps(db, redis, client, NIGHT),
);
expect(r).toMatchObject({ status: "done", nodes: 2, calls: 0 });
expect(client.carsParameters).not.toHaveBeenCalled();
expect(client.groups).not.toHaveBeenCalled();
expect(inserts.flat().map((x) => x.name)).toEqual(["Engine", "Oil"]);
});
it("vehicle: skips catalogs that already have categories", async () => {
const { db } = fakeDb([[cv], [{ one: 1 }]]);
const client = fakeClient({});

View File

@@ -3,6 +3,7 @@ import { and, eq, inArray, notExists, sql } from "drizzle-orm";
import { PostgresJsDatabase } from "drizzle-orm/postgres-js";
import { catalogVehicles, categories } from "../../database/schema/core";
import {
type CarcatGroup,
type CarcatModel,
CarcatonlineClient,
CarcatonlineRateLimitError,
@@ -16,6 +17,7 @@ import {
CARCAT_KEYS,
type CarcatConfig,
CarcatonlineBudgetError,
CarcatonlineHourlyCapError,
CarcatonlineLockedError,
CarcatonlineThrottle,
CarcatonlineWindowClosedError,
@@ -131,8 +133,8 @@ async function scan(
.from(categories)
.where(eq(categories.catalogVehicleId, catalogVehicles.id)),
),
// never tried, or tried more than RETRY_AFTER_DAYS ago
sql`(${catalogVehicles.metadata}->'carcatonline'->>'at') IS NULL OR (${catalogVehicles.metadata}->'carcatonline'->>'at') < ${cutoff}`,
// never tried, mid-way (matched → resume), or a terminal outcome older than RETRY_AFTER_DAYS
sql`(${catalogVehicles.metadata}->'carcatonline'->>'at') IS NULL OR (${catalogVehicles.metadata}->'carcatonline'->>'status') = 'matched' OR (${catalogVehicles.metadata}->'carcatonline'->>'at') < ${cutoff}`,
),
)
.orderBy(
@@ -195,6 +197,25 @@ async function fillVehicle(
const client = deps.client();
const hooks = {
beforeCall: () => throttle.beforeCall(),
cache: {
get: async (key: string) => {
const raw = await deps.redis.get(`${CARCAT_KEYS.groupsPrefix}${key}`);
if (!raw) return null;
try {
return JSON.parse(raw) as CarcatGroup[];
} catch {
return null;
}
},
set: async (key: string, groups: CarcatGroup[]) => {
await deps.redis.set(
`${CARCAT_KEYS.groupsPrefix}${key}`,
JSON.stringify(groups),
"EX",
24 * 3600,
);
},
},
checkAbort: () => {
if (!force && !isWithinWindow(now(), cfg.windowStartHour, cfg.windowEndHour)) {
throw new CarcatonlineWindowClosedError(
@@ -254,8 +275,29 @@ async function fillVehicle(
return { status: "unmatched", reason: "model" };
}
// 2. representative car
const car = await resolveRepresentativeCar(client, hooks, match.catalogId, match.modelId);
// 2. representative car — reuse the one resolved by an earlier (quota-stopped) run
const prev = (cv.metadata as { carcatonline?: CarcatMetadata } | null)?.carcatonline;
const resumable =
prev?.status === "matched" &&
prev.catalogId === match.catalogId &&
prev.modelId === match.modelId &&
prev.carId
? {
carId: prev.carId,
parameters: (prev.parameters ?? []).map((p) => ({ ...p, name: p.key })),
}
: null;
const car =
resumable ?? (await resolveRepresentativeCar(client, hooks, match.catalogId, match.modelId));
if (car && !resumable) {
await setMeta({
status: "matched",
...match,
carId: car.carId,
parameters: car.parameters.map((p) => ({ key: p.key, value: p.value, idx: p.idx })),
at: now().toISOString(),
});
}
if (!car) {
await setMeta({ status: "no_car", ...match, at: now().toISOString() });
return { status: "no_car" };
@@ -305,6 +347,9 @@ async function fillVehicle(
}
if (err instanceof CarcatonlineLockedError)
await reschedule(err.retryAfterMs + 30_000, "locked out");
if (err instanceof CarcatonlineHourlyCapError) {
await reschedule(err.retryAfterMs, err.message);
}
if (err instanceof CarcatonlineBudgetError) {
await reschedule(
msUntilWindowOpens(now(), cfg.windowStartHour, cfg.windowEndHour) + 3600_000,

View File

@@ -84,6 +84,7 @@ services:
- CARCATONLINE_EMAIL=${CARCATONLINE_EMAIL:-}
- CARCATONLINE_PASSWORD=${CARCATONLINE_PASSWORD:-}
- CARCATONLINE_DAILY_CALL_CAP=${CARCATONLINE_DAILY_CALL_CAP:-15000}
- CARCATONLINE_HOURLY_CALL_CAP=${CARCATONLINE_HOURLY_CALL_CAP:-90}
- CARCATONLINE_WINDOW_START=${CARCATONLINE_WINDOW_START:-20}
- CARCATONLINE_WINDOW_END=${CARCATONLINE_WINDOW_END:-7}
- CARCATONLINE_MIN_INTERVAL_MS=${CARCATONLINE_MIN_INTERVAL_MS:-7000}
@@ -260,6 +261,7 @@ services:
- CARCATONLINE_EMAIL=${CARCATONLINE_EMAIL:-}
- CARCATONLINE_PASSWORD=${CARCATONLINE_PASSWORD:-}
- CARCATONLINE_DAILY_CALL_CAP=${CARCATONLINE_DAILY_CALL_CAP:-15000}
- CARCATONLINE_HOURLY_CALL_CAP=${CARCATONLINE_HOURLY_CALL_CAP:-90}
- CARCATONLINE_WINDOW_START=${CARCATONLINE_WINDOW_START:-20}
- CARCATONLINE_WINDOW_END=${CARCATONLINE_WINDOW_END:-7}
- CARCATONLINE_MIN_INTERVAL_MS=${CARCATONLINE_MIN_INTERVAL_MS:-7000}

View File

@@ -106,6 +106,7 @@ export const envSchema = z.object({
CARCATONLINE_EMAIL: z.string().optional(),
CARCATONLINE_PASSWORD: z.string().optional(),
CARCATONLINE_DAILY_CALL_CAP: z.coerce.number().int().min(0).default(15000),
CARCATONLINE_HOURLY_CALL_CAP: z.coerce.number().int().min(0).default(90),
CARCATONLINE_WINDOW_START: z.coerce.number().int().min(0).max(23).default(20),
CARCATONLINE_WINDOW_END: z.coerce.number().int().min(0).max(23).default(7),
CARCATONLINE_MIN_INTERVAL_MS: z.coerce.number().int().min(500).default(7000),