mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 04:50:59 +00:00
The request-scoped capture kept the last verdict of any kind and fell back to it when nothing had refused. That fallback fires in exactly one state — no limiter refused this request — which is the definition of "this 429 came from somewhere else". So a daily AI budget that is spent until midnight, an hourly generation quota, and a provider-side 429 relayed onward all went out carrying our own bucket's numbers: a remainder that says there is room left and a Retry-After measured against an unrelated window. On the AI paths the captured window can also expire while the provider call runs, so the dressed delay could land at zero and tell the client to retry immediately into the ceiling that had just refused it. The cell now keeps refusals only. A request every bucket let through reports nothing, and a 429 from another cause leaves undressed, which is correct: there is no bucket to describe. The two-limiter rule is unchanged — the last refusal wins, and a refusal still outranks a pass. The unit case that pinned the fallback is turned round, and both directions now run through the real wrapper: a limiter refusal gets the headers, a 429 raised without one gets none. The integration file gains the two shapes it was missing — a route that consults two buckets must report the one that refused, and a route whose 429 comes from the daily budget must carry no rate-limit headers at all.
192 lines
5.5 KiB
TypeScript
192 lines
5.5 KiB
TypeScript
/**
|
|
* Which 429s `apiHandler` is allowed to dress with rate-limit headers.
|
|
*
|
|
* The wrapper attaches `Retry-After` and the `X-RateLimit` triple to any 429
|
|
* that leaves a handler without them, and it takes the numbers from the
|
|
* request-scoped limiter capture. That is only honest when a limiter actually
|
|
* refused: an AI budget that is spent for the day, an hourly generation quota
|
|
* and a provider-side 429 relayed onward are all 429s no bucket produced, and
|
|
* describing them with a bucket that still has room tells the client to retry
|
|
* into a ceiling that has not moved.
|
|
*
|
|
* So both directions are pinned here: a refusal from the limiter gets the
|
|
* headers, a 429 from anything else gets none.
|
|
*/
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import { describe, expect, it, vi } from "vitest";
|
|
|
|
// --- Mocks must be hoisted before importing the module under test. ---
|
|
|
|
vi.mock("@/lib/db", () => ({
|
|
prisma: {},
|
|
}));
|
|
|
|
vi.mock("@/lib/auth/session", () => ({
|
|
getSession: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/auth/hmac", () => ({
|
|
hashToken: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("@/lib/auth/audit", () => ({
|
|
auditLog: vi.fn().mockResolvedValue(undefined),
|
|
}));
|
|
|
|
vi.mock("@/lib/logging/transports", () => ({
|
|
emitIfSampled: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("next/headers", () => ({
|
|
headers: vi.fn(async () => ({ get: () => null })),
|
|
cookies: vi.fn(async () => ({
|
|
get: () => undefined,
|
|
set: () => {},
|
|
delete: () => {},
|
|
})),
|
|
}));
|
|
|
|
import { apiHandler } from "@/lib/api-handler";
|
|
import { captureRateLimitResult } from "@/lib/rate-limit-context";
|
|
|
|
const RATE_LIMIT_HEADERS = [
|
|
"Retry-After",
|
|
"X-RateLimit-Limit",
|
|
"X-RateLimit-Remaining",
|
|
"X-RateLimit-Reset",
|
|
] as const;
|
|
|
|
function refusal(): NextResponse {
|
|
return NextResponse.json({ data: null, error: "Too many" }, { status: 429 });
|
|
}
|
|
|
|
function request(): NextRequest {
|
|
return new NextRequest("http://localhost/api/test", { method: "POST" });
|
|
}
|
|
|
|
describe("apiHandler — 429 header dressing", () => {
|
|
it("dresses a 429 the limiter refused", async () => {
|
|
const resetAt = Date.now() + 30_000;
|
|
const route = apiHandler(async (_request: NextRequest) => {
|
|
captureRateLimitResult({
|
|
allowed: false,
|
|
limit: 60,
|
|
remaining: 0,
|
|
resetAt,
|
|
});
|
|
return refusal();
|
|
});
|
|
|
|
const res = await route(request());
|
|
|
|
expect(res.status).toBe(429);
|
|
expect(res.headers.get("X-RateLimit-Limit")).toBe("60");
|
|
expect(res.headers.get("X-RateLimit-Remaining")).toBe("0");
|
|
expect(res.headers.get("X-RateLimit-Reset")).toBe(
|
|
new Date(resetAt).toISOString(),
|
|
);
|
|
expect(Number(res.headers.get("Retry-After"))).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("leaves a 429 no limiter produced undressed", async () => {
|
|
const route = apiHandler(async (_request: NextRequest) => refusal());
|
|
|
|
const res = await route(request());
|
|
|
|
expect(res.status).toBe(429);
|
|
for (const name of RATE_LIMIT_HEADERS) {
|
|
expect(res.headers.get(name)).toBeNull();
|
|
}
|
|
});
|
|
|
|
it("leaves a 429 undressed when every bucket the handler consulted passed", async () => {
|
|
// The shape of the AI surfaces: a per-minute limiter lets the request
|
|
// through, then a daily budget or an upstream provider refuses it. The
|
|
// window the limiter cleared says nothing about when the budget resets.
|
|
const route = apiHandler(async (_request: NextRequest) => {
|
|
captureRateLimitResult({
|
|
allowed: true,
|
|
limit: 10,
|
|
remaining: 9,
|
|
resetAt: Date.now() + 60_000,
|
|
});
|
|
return refusal();
|
|
});
|
|
|
|
const res = await route(request());
|
|
|
|
expect(res.status).toBe(429);
|
|
for (const name of RATE_LIMIT_HEADERS) {
|
|
expect(res.headers.get(name)).toBeNull();
|
|
}
|
|
});
|
|
|
|
it("reports the bucket that refused when the handler consulted two", async () => {
|
|
const resetAt = Date.now() + 45_000;
|
|
const route = apiHandler(async (_request: NextRequest) => {
|
|
captureRateLimitResult({
|
|
allowed: true,
|
|
limit: 600,
|
|
remaining: 599,
|
|
resetAt: Date.now() + 3_600_000,
|
|
});
|
|
captureRateLimitResult({
|
|
allowed: false,
|
|
limit: 5,
|
|
remaining: 0,
|
|
resetAt,
|
|
});
|
|
return refusal();
|
|
});
|
|
|
|
const res = await route(request());
|
|
|
|
expect(res.headers.get("X-RateLimit-Limit")).toBe("5");
|
|
expect(res.headers.get("X-RateLimit-Reset")).toBe(
|
|
new Date(resetAt).toISOString(),
|
|
);
|
|
});
|
|
|
|
it("keeps the headers a handler attached itself", async () => {
|
|
const route = apiHandler(async (_request: NextRequest) => {
|
|
captureRateLimitResult({
|
|
allowed: false,
|
|
limit: 60,
|
|
remaining: 0,
|
|
resetAt: Date.now() + 30_000,
|
|
});
|
|
return NextResponse.json(
|
|
{ data: null, error: "Too many" },
|
|
{
|
|
status: 429,
|
|
headers: { "Retry-After": "900", "X-RateLimit-Limit": "3" },
|
|
},
|
|
);
|
|
});
|
|
|
|
const res = await route(request());
|
|
|
|
expect(res.headers.get("Retry-After")).toBe("900");
|
|
expect(res.headers.get("X-RateLimit-Limit")).toBe("3");
|
|
});
|
|
|
|
it("leaves a non-429 response alone even after a refusal was captured", async () => {
|
|
const route = apiHandler(async (_request: NextRequest) => {
|
|
captureRateLimitResult({
|
|
allowed: false,
|
|
limit: 60,
|
|
remaining: 0,
|
|
resetAt: Date.now() + 30_000,
|
|
});
|
|
return NextResponse.json({ data: { ok: true }, error: null });
|
|
});
|
|
|
|
const res = await route(request());
|
|
|
|
expect(res.status).toBe(200);
|
|
for (const name of RATE_LIMIT_HEADERS) {
|
|
expect(res.headers.get(name)).toBeNull();
|
|
}
|
|
});
|
|
});
|