fix(capacity): limit each user and queue heavy compute briefly.
Consult, rectification turns, and report generation stop before a credit reservation when the product caps are hit. A full heavy-compute gate waits up to 8 seconds, and one consultation retries that 429 inside the same request. Co-Authored-By: Grok (agent loop)
This commit is contained in:
co-authored by
Grok (agent loop)
parent
ef49257b73
commit
a54f75e5e3
@@ -0,0 +1,102 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { ConsultationWorkflowError } from "../src/mastra/consultation-workflow.ts";
|
||||
import { CONSULTATION_QUEUE_LABEL } from "../src/lib/consultation-activity-labels.ts";
|
||||
import {
|
||||
CONSULTATION_QUEUE_FAILED_MESSAGE,
|
||||
consultationRunFailureMessage,
|
||||
isConsultationRateLimitFailure,
|
||||
retryOnConsultationRateLimit,
|
||||
} from "../src/lib/consultation-workflow-retry.ts";
|
||||
import {
|
||||
emptyConsultationTimeline,
|
||||
reduceConsultationTimelineEvents,
|
||||
} from "../src/lib/consultation-run-timeline.ts";
|
||||
|
||||
test("a 429 is retried once and the queue line is shown before success", async () => {
|
||||
let calls = 0;
|
||||
const queued: string[] = [];
|
||||
const sleeps: number[] = [];
|
||||
const result = await retryOnConsultationRateLimit({
|
||||
run: async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new ConsultationWorkflowError("workflow_rate_limited", "busy", 2);
|
||||
return "ok";
|
||||
},
|
||||
onQueue: () => { queued.push(CONSULTATION_QUEUE_LABEL); },
|
||||
sleep: async (ms) => { sleeps.push(ms); },
|
||||
});
|
||||
assert.equal(result, "ok");
|
||||
assert.equal(calls, 2);
|
||||
assert.deepEqual(sleeps, [2000]);
|
||||
assert.deepEqual(queued, ["人有点多,排一下队"]);
|
||||
});
|
||||
|
||||
test("three 429s stop within two retries and 15 seconds", async () => {
|
||||
let calls = 0;
|
||||
let slept = 0;
|
||||
await assert.rejects(
|
||||
() => retryOnConsultationRateLimit({
|
||||
run: async () => {
|
||||
calls += 1;
|
||||
throw new ConsultationWorkflowError("workflow_rate_limited", "busy", 2);
|
||||
},
|
||||
sleep: async (ms) => { slept += ms; },
|
||||
}),
|
||||
(error: unknown) => {
|
||||
assert.equal(isConsultationRateLimitFailure(error), true);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
assert.equal(calls, 3);
|
||||
assert.equal(slept, 4_000);
|
||||
assert.ok(slept <= 15_000);
|
||||
assert.equal(
|
||||
consultationRunFailureMessage(new ConsultationWorkflowError("workflow_rate_limited", "busy", 2), "其他失败"),
|
||||
CONSULTATION_QUEUE_FAILED_MESSAGE,
|
||||
);
|
||||
assert.equal(consultationRunFailureMessage(new Error("other"), "其他失败"), "其他失败");
|
||||
});
|
||||
|
||||
test("Retry-After cannot push the total wait past 15 seconds", async () => {
|
||||
let calls = 0;
|
||||
let slept = 0;
|
||||
await assert.rejects(() => retryOnConsultationRateLimit({
|
||||
run: async () => {
|
||||
calls += 1;
|
||||
throw new ConsultationWorkflowError("workflow_rate_limited", "busy", 20);
|
||||
},
|
||||
sleep: async (ms) => { slept += ms; },
|
||||
}));
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(slept, 15_000);
|
||||
});
|
||||
|
||||
test("the queue sentence replaces the calculate row and the chart label comes back", () => {
|
||||
const calculating = reduceConsultationTimelineEvents([
|
||||
{ type: "tool.started", callId: "tool-1", tool: "run-jyotish-consultation", label: "正在计算个人星盘" },
|
||||
{ type: "activity", phase: "chart-calculation", label: "正在计算本命盘…" },
|
||||
], emptyConsultationTimeline());
|
||||
const queued = reduceConsultationTimelineEvents([
|
||||
{ type: "activity", phase: "compute-queue", label: CONSULTATION_QUEUE_LABEL },
|
||||
], calculating);
|
||||
assert.equal(queued.rows.find((row) => row.kind === "calculate")?.label, "人有点多,排一下队");
|
||||
assert.equal(queued.rows.find((row) => row.kind === "calculate")?.status, "live");
|
||||
const resumed = reduceConsultationTimelineEvents([
|
||||
{ type: "activity", phase: "chart-calculation", label: "正在计算本命盘…" },
|
||||
], queued);
|
||||
assert.equal(resumed.rows.find((row) => row.kind === "calculate")?.label, "正在计算本命盘…");
|
||||
});
|
||||
|
||||
test("a failed consultation still settles through the existing cancel path before the queue sentence", () => {
|
||||
const stream = readFileSync(new URL("../src/lib/stream-agent-response.ts", import.meta.url), "utf8");
|
||||
const consult = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
||||
const onErrorAt = stream.indexOf("await options.onError?.");
|
||||
const messageAt = stream.indexOf("consultationRunFailureMessage(error, runFailedMessage(code))");
|
||||
assert.ok(onErrorAt > 0 && messageAt > onErrorAt);
|
||||
assert.match(consult, /onError: \(error\) => settleRun\(\s*cancel/);
|
||||
assert.match(consult, /else await cancel\(\)/);
|
||||
assert.match(consult, /retryOnConsultationRateLimit/);
|
||||
assert.match(consult, /CONSULTATION_QUEUE_FAILED_MESSAGE/);
|
||||
});
|
||||
@@ -710,6 +710,19 @@ test("core create: 429 at the daily limit", async () => {
|
||||
assert.equal(response.body.code, "report_rate_limited");
|
||||
});
|
||||
|
||||
test("core create: hourly rate limit returns 429 and does not reserve", async () => {
|
||||
const billing = billingHarness();
|
||||
const response = await resolveReportCreate(baseDeps({
|
||||
billing: billing.billing,
|
||||
consumeReportRateLimit: () => ({ ok: false, retryAfterSeconds: 12, message: "这一小时的报告已经够多了,先停一下。" }),
|
||||
}));
|
||||
assert.equal(response.status, 429);
|
||||
assert.equal(response.body.code, "rate_limited");
|
||||
assert.equal(response.body.retryAfterSeconds, 12);
|
||||
assert.equal(response.headers?.["Retry-After"], "12");
|
||||
assert.equal(billing.reservations, 0);
|
||||
});
|
||||
|
||||
test("core create: 502 model_unavailable when no model is configured", async () => {
|
||||
const persistence = new MemoryPersistence();
|
||||
const response = await resolveReportCreate(baseDeps({
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import {
|
||||
consumeRequestRateLimit,
|
||||
consumeUserRateLimitSet,
|
||||
requestRateLimits,
|
||||
resetRequestRateLimitMaintenanceForTests,
|
||||
userRateLimitDeniedResponse,
|
||||
userRateLimitMessages,
|
||||
} from "../src/lib/request-rate-limit.ts";
|
||||
|
||||
test("a user is allowed up to the window limit and then must wait", () => {
|
||||
@@ -110,3 +115,98 @@ test("keys past the longest seen window are swept, and a one-hour key survives 6
|
||||
assert.equal(mixed.has("ephemeris:person"), true);
|
||||
resetRequestRateLimitMaintenanceForTests();
|
||||
});
|
||||
|
||||
test("consult allows 10 per minute and refuses the 11th without charging the hour bucket further", () => {
|
||||
const store = new Map<string, number[]>();
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const decision = consumeUserRateLimitSet(
|
||||
["consultMinute", "consultHour"],
|
||||
"person",
|
||||
1_000 + index,
|
||||
store,
|
||||
);
|
||||
assert.deepEqual(decision, { ok: true });
|
||||
}
|
||||
const denied = consumeUserRateLimitSet(["consultMinute", "consultHour"], "person", 2_000, store);
|
||||
assert.equal(denied.ok, false);
|
||||
if (!denied.ok) assert.equal(denied.retryAfterSeconds, 59);
|
||||
assert.equal(store.get("consultMinute:person")?.length, 10);
|
||||
assert.equal(store.get("consultHour:person")?.length, 10);
|
||||
assert.equal(requestRateLimits.consultMinute.limit, 10);
|
||||
assert.equal(requestRateLimits.consultHour.limit, 120);
|
||||
});
|
||||
|
||||
test("the hour bucket can refuse while the minute bucket still has room, and neither bucket records the miss", () => {
|
||||
const store = new Map<string, number[]>();
|
||||
for (let index = 0; index < 120; index += 1) {
|
||||
consumeRequestRateLimit({
|
||||
key: "consultHour:person",
|
||||
limit: 120,
|
||||
windowMs: 60 * 60_000,
|
||||
now: index * 1_000,
|
||||
store,
|
||||
});
|
||||
}
|
||||
const denied = consumeUserRateLimitSet(["consultMinute", "consultHour"], "person", 130_000, store);
|
||||
assert.equal(denied.ok, false);
|
||||
assert.equal(store.has("consultMinute:person"), false);
|
||||
assert.equal(store.get("consultHour:person")?.length, 120);
|
||||
});
|
||||
|
||||
test("rectification turns allow 5 per minute and 40 per hour, reports allow 3 per hour", () => {
|
||||
const store = new Map<string, number[]>();
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
assert.deepEqual(
|
||||
consumeUserRateLimitSet(["rectificationTurnMinute", "rectificationTurnHour"], "person", 5_000 + index, store),
|
||||
{ ok: true },
|
||||
);
|
||||
}
|
||||
const denied = consumeUserRateLimitSet(
|
||||
["rectificationTurnMinute", "rectificationTurnHour"],
|
||||
"person",
|
||||
5_100,
|
||||
store,
|
||||
);
|
||||
assert.equal(denied.ok, false);
|
||||
assert.equal(store.get("rectificationTurnMinute:person")?.length, 5);
|
||||
assert.equal(requestRateLimits.rectificationTurnMinute.limit, 5);
|
||||
assert.equal(requestRateLimits.rectificationTurnHour.limit, 40);
|
||||
assert.equal(requestRateLimits.reportGenerateHour.limit, 3);
|
||||
assert.equal(requestRateLimits.reportGenerateHour.windowMs, 60 * 60_000);
|
||||
|
||||
const reports = new Map<string, number[]>();
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
assert.deepEqual(
|
||||
consumeUserRateLimitSet(["reportGenerateHour"], "person", 8_000 + index, reports),
|
||||
{ ok: true },
|
||||
);
|
||||
}
|
||||
const reportDenied = consumeUserRateLimitSet(["reportGenerateHour"], "person", 9_000, reports);
|
||||
assert.equal(reportDenied.ok, false);
|
||||
assert.equal(reports.get("reportGenerateHour:person")?.length, 3);
|
||||
});
|
||||
|
||||
test("a denied response carries 429, rate_limited, and Retry-After", () => {
|
||||
const denied = userRateLimitDeniedResponse(12, userRateLimitMessages.consult);
|
||||
assert.equal(denied.status, 429);
|
||||
assert.equal(denied.headers["Retry-After"], "12");
|
||||
assert.deepEqual(denied.body, {
|
||||
error: "这一会儿问得有点密,先停一下。",
|
||||
code: "rate_limited",
|
||||
retryAfterSeconds: 12,
|
||||
});
|
||||
});
|
||||
|
||||
test("consult, rectification turns, and report creation check the limit before they reserve", () => {
|
||||
const consult = readFileSync(new URL("../src/app/api/consult/route.ts", import.meta.url), "utf8");
|
||||
const rectification = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
|
||||
const caseOpen = readFileSync(new URL("../src/app/api/rectification/cases/open/route.ts", import.meta.url), "utf8");
|
||||
const reports = readFileSync(new URL("../src/app/api/reports/route.ts", import.meta.url), "utf8");
|
||||
const reportCore = readFileSync(new URL("../src/lib/personal-report-route-core.ts", import.meta.url), "utf8");
|
||||
assert.ok(consult.indexOf("consumeUserRateLimitSet(") < consult.indexOf("reserve_consultation_usage"));
|
||||
assert.match(consult, /userRateLimitDeniedResponse\(/);
|
||||
assert.ok(rectification.indexOf("consumeUserRateLimitSet(") < rectification.indexOf("respondWithRectificationAgentTurn("));
|
||||
assert.equal(caseOpen.includes("consumeUserRateLimitSet"), false);
|
||||
assert.match(reports, /consumeUserRateLimitSet\(\["reportGenerateHour"\]/);
|
||||
assert.ok(reportCore.indexOf("if (deps.consumeReportRateLimit)") < reportCore.indexOf("deps.billing.reserve"));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user