Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions src/app/api/eval-cases/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { normalizedClinicalSearchTokens } from "@/lib/clinical-search";
import { clinicalQueryModeSchema } from "@/lib/clinical-query-mode";
import { env, isDemoMode } from "@/lib/env";
Expand Down Expand Up @@ -124,6 +125,17 @@ export async function POST(request: Request) {

const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);

const rateLimit = await consumeApiRateLimit({
supabase,
ownerId: user.id,
bucket: "ingestion_admin",
allowInMemoryFallbackOnUnavailable: true,
});
if (rateLimit.limited) {
return rateLimitJsonResponse("Too many ingestion administration requests. Retry shortly.", rateLimit);
}

const normalizedQuery = normalizedQueryTextForStorage(parsed.query);
const sourceChunkIds = uniqueUuidValues(parsed.sourceChunkIds);
const citedChunkIds = uniqueUuidValues(parsed.citedChunkIds);
Expand Down
12 changes: 12 additions & 0 deletions src/app/api/ingestion/quality/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { consumeApiRateLimit, rateLimitJsonResponse } from "@/lib/api-rate-limit";
import { isDemoMode } from "@/lib/env";
import { jsonError } from "@/lib/http";
import { createAdminClient } from "@/lib/supabase/admin";
Expand Down Expand Up @@ -321,6 +322,17 @@ export async function GET(request: Request) {

const supabase = createAdminClient();
const user = await requireAuthenticatedUser(request, supabase);

const rateLimit = await consumeApiRateLimit({
supabase,
ownerId: user.id,
bucket: "ingestion_admin",
allowInMemoryFallbackOnUnavailable: true,
});
if (rateLimit.limited) {
return rateLimitJsonResponse("Too many ingestion administration requests. Retry shortly.", rateLimit);
}

const { limit } = parseRequestQuery(request, ingestionQualityQuerySchema, "Invalid ingestion quality query.");

const { data: documentsData, error: documentsError } = await supabase
Expand Down
6 changes: 5 additions & 1 deletion src/lib/api-rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ export type ApiRateLimitBucket =
| "bulk_reindex"
| "source_review"
| "answer_feedback"
| "registry";
| "registry"
| "ingestion_admin";

export type ApiRateLimitResult = {
limited: boolean;
Expand All @@ -60,6 +61,9 @@ const apiRateLimitDefaults = {
source_review: { limit: 30, windowSeconds: 60 },
answer_feedback: { limit: 30, windowSeconds: 60 },
registry: { limit: 120, windowSeconds: 60 },
// Authenticated owner ingestion/eval admin tooling (ingestion-quality dashboard, eval-case capture).
// Generous for interactive/polling admin use, bounded against an abusive/compromised client.
ingestion_admin: { limit: 60, windowSeconds: 60 },
} as const satisfies Record<ApiRateLimitBucket, { limit: number; windowSeconds: number }>;

const anonymousApiRateLimitDefaults: Partial<Record<ApiRateLimitBucket, { limit: number; windowSeconds: number }>> = {
Expand Down
17 changes: 17 additions & 0 deletions tests/eval-cases-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,23 @@ function createInsertMock(
expect(table).toBe("rag_query_misses");
return { insert };
}),
// The route consults the ingestion_admin rate limiter before touching tables.
rpc: vi.fn(async (name: string) =>
name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit"
? {
data: [
{
limited: false,
limit_value: 60,
remaining: 59,
retry_after_seconds: 60,
reset_at: new Date(Date.now() + 60_000).toISOString(),
},
],
error: null,
}
: { data: [], error: null },
),
},
};
}
Expand Down
79 changes: 79 additions & 0 deletions tests/ingestion-admin-rate-limit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it, vi } from "vitest";

// Proves the `ingestion_admin` bucket enforces a 429 on the authenticated
// ingestion/eval admin routes when the durable limiter reports the bucket
// exhausted. The routes pass `allowInMemoryFallbackOnUnavailable: true`, so a
// valid `limited: true` row is honoured directly (the fallback only engages when
// the limiter errors).

const userId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";

function rateLimitedClient() {
return {
from: vi.fn(() => {
throw new Error("no table access should occur once the request is rate limited");
}),
rpc: vi.fn(async (name: string) => {
if (name === "consume_api_rate_limit") {
return {
data: [
{
limited: true,
limit_value: 60,
remaining: 0,
retry_after_seconds: 37,
reset_at: new Date(Date.now() + 37_000).toISOString(),
},
],
error: null,
};
}
return { data: [], error: null };
}),
};
}

function mockRuntime(client: ReturnType<typeof rateLimitedClient>) {
vi.resetModules();
vi.doMock("@/lib/env", () => ({
env: {
RAG_PERSIST_RAW_QUERY_TEXT: false,
RAG_PERSIST_ANSWER_TEXT: false,
},
isDemoMode: () => false,
isLocalNoAuthMode: () => false,
requireOpenAIEnv: () => undefined,
requireServerEnv: () => undefined,
}));
vi.doMock("@/lib/supabase/admin", () => ({ createAdminClient: () => client }));
vi.doMock("@/lib/supabase/auth", () => ({
AuthenticationError: class AuthenticationError extends Error {},
requireAuthenticatedUser: vi.fn(async () => ({ id: userId })),
unauthorizedResponse: () => new Response(JSON.stringify({ error: "Authentication required." }), { status: 401 }),
}));
}

afterEach(() => {
vi.restoreAllMocks();
vi.resetModules();
});

describe("ingestion-admin rate limiting", () => {
it("returns 429 + Retry-After on the ingestion-quality route when the bucket is exhausted, before any table access", async () => {
const client = rateLimitedClient();
mockRuntime(client);
const { GET } = await import("../src/app/api/ingestion/quality/route");

const response = await GET(
new Request("http://localhost/api/ingestion/quality", {
headers: { authorization: "Bearer valid-token" },
}),
);
const body = (await response.json()) as Record<string, unknown>;

expect(response.status).toBe(429);
expect(response.headers.get("retry-after")).toBe("37");
expect(body).toMatchObject({ code: "rate_limited" });
expect(client.from).not.toHaveBeenCalled();
});
});
17 changes: 17 additions & 0 deletions tests/ingestion-quality-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,23 @@ function chainResult(data: unknown[] | null, error: { message: string } | null =
function clientWithTables(tables: Record<string, unknown[]>) {
return {
from: vi.fn((table: string) => chainResult(tables[table] ?? [])),
// The route consults the ingestion_admin rate limiter before touching tables.
rpc: vi.fn(async (name: string) =>
name === "consume_api_rate_limit" || name === "consume_api_subject_rate_limit"
? {
data: [
{
limited: false,
limit_value: 60,
remaining: 59,
retry_after_seconds: 60,
reset_at: new Date(Date.now() + 60_000).toISOString(),
},
],
error: null,
}
: { data: [], error: null },
),
};
}

Expand Down