fix(api): report bad request bodies and fractional limits as client errors (#484)
A malformed JSON body, or a body over the route's size limit, made Hono throw an HTTPException that the API reported as a 500 internal error. In the companion API the same requests returned 503 "unavailable, please retry", so clients retried a request that could never succeed. These now return 400 bad_request and 413 payload_too_large in the API's usual error shape. A fractional limit or offset such as ?limit=1.5 was passed to SQLite as a float and failed with a datatype mismatch. Limits, offsets and cursors are now truncated to whole numbers, and the two private copies of the clamp helper reuse the shared one.
This commit is contained in:
1 parent
be7bbe25e6
commit
518fafa86e
7 files changed
+111
-10
No files matched your search
@@ -0,0 +1,87 @@
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { clampNumber } from "./entity-utils.ts";
|
||||
import { fetchEdgeEverApp } from "./index.ts";
|
||||
import { createSelfHostedStorageAdapter } from "./self-hosted-storage-adapter.ts";
|
||||
|
||||
const temporaryDirectories = [];
|
||||
const databases = [];
|
||||
afterEach(async () => {
|
||||
for (const database of databases.splice(0)) database.close();
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
const createEnvironment = async () => {
|
||||
const directory = await mkdtemp(`${tmpdir()}/edgeever-request-errors-`);
|
||||
temporaryDirectories.push(directory);
|
||||
const database = new Database(join(directory, "edgeever.sqlite"), { create: true });
|
||||
databases.push(database);
|
||||
database.exec("PRAGMA foreign_keys = ON");
|
||||
const migrationDirectory = new URL("../../../migrations/", import.meta.url);
|
||||
for (const name of (await readdir(migrationDirectory)).filter((value) => value.endsWith(".sql")).sort()) {
|
||||
database.exec(await readFile(new URL(name, migrationDirectory), "utf8"));
|
||||
}
|
||||
database.query("INSERT OR IGNORE INTO workspaces (id, name, is_personal) VALUES ('ws_default', 'Personal', 1)").run();
|
||||
return {
|
||||
storage: createSelfHostedStorageAdapter(database, join(directory, "resources")),
|
||||
EDGE_EVER_ALLOW_UNAUTHENTICATED: "true",
|
||||
EDGE_EVER_RUNTIME: "self-hosted-bun",
|
||||
};
|
||||
};
|
||||
|
||||
const request = (environment, path, init = {}) => fetchEdgeEverApp(
|
||||
new Request(`http://edgeever.test${path}`, init),
|
||||
environment,
|
||||
{ waitUntil: () => undefined, passThroughOnException: () => undefined },
|
||||
);
|
||||
|
||||
const jsonPost = (body) => ({
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body,
|
||||
});
|
||||
|
||||
describe("API request errors", () => {
|
||||
test("reports a malformed JSON body as a client error", async () => {
|
||||
const environment = await createEnvironment();
|
||||
const response = await request(environment, "/api/v1/notebooks", jsonPost("{\"name\":"));
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(await response.json()).toEqual({
|
||||
error: { code: "bad_request", message: "Malformed JSON in request body" },
|
||||
});
|
||||
});
|
||||
|
||||
test("reports an oversized body as payload too large", async () => {
|
||||
const environment = await createEnvironment();
|
||||
const oversized = JSON.stringify({ prompt: "x".repeat(450_000) });
|
||||
|
||||
for (const path of ["/api/v1/plugins/ai/generate", "/api/v1/companion/turns"]) {
|
||||
const response = await request(environment, path, jsonPost(oversized));
|
||||
expect({ path, status: response.status }).toEqual({ path, status: 413 });
|
||||
expect(await response.json()).toEqual({
|
||||
error: { code: "payload_too_large", message: "The request body is too large." },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("accepts a fractional list limit instead of failing in SQL", async () => {
|
||||
const environment = await createEnvironment();
|
||||
const response = await request(environment, "/api/v1/memos?limit=1.5");
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray((await response.json()).memos)).toBe(true);
|
||||
});
|
||||
|
||||
test("clamps limits and offsets to whole numbers", () => {
|
||||
expect(clampNumber(1.5, 1, 100)).toBe(1);
|
||||
expect(clampNumber(99.9, 1, 100)).toBe(99);
|
||||
expect(clampNumber(250, 1, 100)).toBe(100);
|
||||
expect(clampNumber(-3, 0, 100)).toBe(0);
|
||||
expect(clampNumber(Number.NaN, 1, 100)).toBe(1);
|
||||
expect(clampNumber(Number.POSITIVE_INFINITY, 0, 50)).toBe(50);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user