perf(webapp): clamp list-endpoint page size to 100 (#4360)

## Summary

Several list endpoints accepted an unbounded page size (`perPage` /
`per_page` / `pageSize`). An unbounded page lets one request pull an
arbitrarily large result set and do a proportional amount of work, which
is a poor default for a shared API.

This clamps the page size to 100 on every list endpoint that was
uncapped, matching the existing cap on `api.v1.runs` and
`api.v1.sessions`. Clamping rather than rejecting keeps existing clients
working: a request for a larger page returns up to 100 items and offset
pagination continues from there.

## Endpoints capped

- `api.v1.schedules` (`perPage`)
- `api.v1.queues` (`perPage`)
- `resources.…versions` (`per_page`)
- `resources.…queues` (`per_page`)
- `admin.api.v1.…engine.report` (`per_page`)
- `admin.api.v1.llm-models` (`pageSize`)

Already capped, left as-is: `api.v1.runs`, `api.v1.sessions`,
`api.v1.deployments`.
This commit is contained in:
Eric Allam
2026-07-24 11:56:58 +01:00
committed by GitHub
parent 9c85e0ecdc
commit 7188eecd83
7 changed files with 38 additions and 6 deletions
@@ -0,0 +1,6 @@
---
area: webapp
type: improvement
---
List API endpoints now clamp the page size to a maximum of 100. Requests asking for a larger page size return up to 100 items and keep paginating, rather than pulling an unbounded page.
@@ -13,7 +13,12 @@ const ParamsSchema = z.object({
const SearchParamsSchema = z.object({
verbose: z.string().default("0"),
page: z.coerce.number().optional(),
per_page: z.coerce.number().optional(),
per_page: z.coerce
.number()
.int()
.positive()
.transform((n) => Math.min(n, 100))
.optional(),
});
export async function loader({ request, params }: LoaderFunctionArgs) {
@@ -9,7 +9,10 @@ export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get("page") ?? "1");
const pageSize = parseInt(url.searchParams.get("pageSize") ?? "50");
const pageSize = Math.max(
1,
Math.min(parseInt(url.searchParams.get("pageSize") ?? "50") || 50, 100)
);
const [models, total] = await Promise.all([
prisma.llmModel.findMany({
+6 -1
View File
@@ -12,7 +12,12 @@ import { ServiceValidationError } from "~/v3/services/baseService.server";
const SearchParamsSchema = z.object({
page: z.coerce.number().int().positive().optional(),
perPage: z.coerce.number().int().positive().optional(),
perPage: z.coerce
.number()
.int()
.positive()
.transform((n) => Math.min(n, 100))
.optional(),
});
export const loader = createLoaderApiRoute(
+6 -1
View File
@@ -12,7 +12,12 @@ import { UpsertTaskScheduleService } from "~/v3/services/upsertTaskSchedule.serv
const SearchParamsSchema = z.object({
page: z.coerce.number().int().positive().optional(),
perPage: z.coerce.number().int().positive().optional(),
perPage: z.coerce
.number()
.int()
.positive()
.transform((n) => Math.min(n, 100))
.optional(),
});
export async function action({ request }: ActionFunctionArgs) {
@@ -9,7 +9,11 @@ import { EnvironmentParamSchema } from "~/utils/pathBuilder";
const SearchParamsSchema = z.object({
query: z.string().optional(),
page: z.coerce.number().min(1).default(1),
per_page: z.coerce.number().min(1).default(20),
per_page: z.coerce
.number()
.min(1)
.transform((n) => Math.min(n, 100))
.default(20),
type: z.enum(["task", "custom"]).optional(),
});
@@ -8,7 +8,11 @@ import { EnvironmentParamSchema } from "~/utils/pathBuilder";
const SearchParamsSchema = z.object({
query: z.string().optional(),
per_page: z.coerce.number().min(1).default(25),
per_page: z.coerce
.number()
.min(1)
.transform((n) => Math.min(n, 100))
.default(25),
});
export async function loader({ request, params }: LoaderFunctionArgs) {