347 lines
12 KiB
TypeScript
Executable File
347 lines
12 KiB
TypeScript
Executable File
import {
|
|
assert,
|
|
assertEquals,
|
|
assertRejects,
|
|
assertStrictEquals,
|
|
} from "@std/assert";
|
|
import {
|
|
GatewayError,
|
|
REBUILT_BODY_HEADERS,
|
|
} from "../../../packages/core/src/mod.ts";
|
|
import {
|
|
dispatchThenDetach,
|
|
MAX_JSON_BODY_BYTES,
|
|
parseJsonBody,
|
|
readCappedBytes,
|
|
readCappedText,
|
|
rebuild,
|
|
} from "./helpers.ts";
|
|
|
|
function jsonRequest(body: string): Request {
|
|
return new Request("http://gateway.test/v1/chat/completions", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body,
|
|
});
|
|
}
|
|
|
|
Deno.test("parseJsonBody parses a normal JSON body", async () => {
|
|
const parsed = await parseJsonBody(jsonRequest('{"model":"m","n":1}'));
|
|
assertEquals(parsed, { model: "m", n: 1 });
|
|
});
|
|
|
|
Deno.test("parseJsonBody rejects a body over the size cap with 413", async () => {
|
|
const big = JSON.stringify({ blob: "x".repeat(64) });
|
|
let thrown: unknown;
|
|
try {
|
|
await parseJsonBody(jsonRequest(big), 16); // cap below the body size
|
|
} catch (error) {
|
|
thrown = error;
|
|
}
|
|
assert(thrown instanceof GatewayError, "expected a GatewayError");
|
|
assertEquals((thrown as GatewayError).status, 413);
|
|
});
|
|
|
|
Deno.test("parseJsonBody still rejects invalid JSON with 400", async () => {
|
|
let thrown: unknown;
|
|
try {
|
|
await parseJsonBody(jsonRequest("{not json"));
|
|
} catch (error) {
|
|
thrown = error;
|
|
}
|
|
assert(thrown instanceof GatewayError);
|
|
assertEquals((thrown as GatewayError).status, 400);
|
|
});
|
|
|
|
Deno.test("MAX_JSON_BODY_BYTES is a sane positive bound", () => {
|
|
assert(MAX_JSON_BODY_BYTES > 0);
|
|
});
|
|
|
|
function streamOf(...chunks: Uint8Array[]): ReadableStream<Uint8Array> {
|
|
return new ReadableStream({
|
|
start(controller) {
|
|
for (const chunk of chunks) {
|
|
controller.enqueue(chunk);
|
|
}
|
|
controller.close();
|
|
},
|
|
});
|
|
}
|
|
|
|
/** Races `work` against a deadline without leaking the timer into the sanitizer. */
|
|
async function withinMs<T>(ms: number, work: Promise<T>): Promise<T | "HUNG"> {
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
const deadline = new Promise<"HUNG">((resolve) => {
|
|
timer = setTimeout(() => resolve("HUNG"), ms);
|
|
});
|
|
try {
|
|
return await Promise.race([work, deadline]);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
Deno.test("readCappedBytes returns the exact bytes across chunk boundaries", async () => {
|
|
const bytes = await readCappedBytes(
|
|
streamOf(new Uint8Array([1, 2, 3]), new Uint8Array([4, 5])),
|
|
64,
|
|
);
|
|
assertEquals([...bytes], [1, 2, 3, 4, 5]);
|
|
});
|
|
|
|
Deno.test("readCappedBytes treats a null body as zero bytes", async () => {
|
|
assertEquals((await readCappedBytes(null, 64)).byteLength, 0);
|
|
});
|
|
|
|
Deno.test("readCappedBytes 413 boundary: total === maxBytes passes, one more throws", async () => {
|
|
assertEquals(
|
|
(await readCappedBytes(streamOf(new Uint8Array(64)), 64)).byteLength,
|
|
64,
|
|
);
|
|
const err = await assertRejects(
|
|
() => readCappedBytes(streamOf(new Uint8Array(64)), 63),
|
|
GatewayError,
|
|
);
|
|
assertEquals(err.status, 413);
|
|
});
|
|
|
|
Deno.test("readCappedBytes 413 fires on the chunk that crosses the cap", async () => {
|
|
// First chunk is under the cap, second crosses it: the check must be
|
|
// cumulative, not per-chunk.
|
|
const err = await assertRejects(
|
|
() => readCappedBytes(streamOf(new Uint8Array(40), new Uint8Array(40)), 64),
|
|
GatewayError,
|
|
);
|
|
assertEquals(err.status, 413);
|
|
});
|
|
|
|
Deno.test("readCappedBytes does not await cancel: a tee'd branch still yields its 413", async () => {
|
|
// A tee branch's cancel() promise settles only once BOTH branches cancel
|
|
// (measured: awaiting it here never resolves), so an awaited cancel would
|
|
// hang instead of producing the 413. This is the guard on helpers.ts:43-46.
|
|
const [a, b] = streamOf(new Uint8Array(32), new Uint8Array(32)).tee();
|
|
const outcome = await withinMs(
|
|
1500,
|
|
readCappedBytes(a, 8).then((): unknown => "resolved").catch((e) => e),
|
|
);
|
|
assert(
|
|
outcome instanceof GatewayError,
|
|
`expected a GatewayError, got ${String(outcome)}`,
|
|
);
|
|
assertEquals(outcome.status, 413);
|
|
await b.cancel();
|
|
});
|
|
|
|
Deno.test("readCappedText does not await cancel: a req.clone() of a STREAM body still yields its 413", async () => {
|
|
// The shape that actually occurs in the tree, and the one the tee arm above
|
|
// does not cover: governance.ts pre-auth-reads `req.clone()`. A clone of a
|
|
// stream-sourced body is a tee underneath, so its cancel promise settles only
|
|
// once the original is cancelled too - an awaited cancel hangs here exactly
|
|
// as it does on an explicit .tee(). A clone of a BUFFERED body resolves
|
|
// promptly, which is why measuring only that shape makes awaiting look safe.
|
|
const req = new Request("http://gateway.test/v1/chat/completions", {
|
|
method: "POST",
|
|
body: streamOf(new Uint8Array(32), new Uint8Array(32)),
|
|
});
|
|
const outcome = await withinMs(
|
|
1500,
|
|
readCappedText(req.clone(), 8).then((): unknown => "resolved").catch((e) =>
|
|
e
|
|
),
|
|
);
|
|
assert(
|
|
outcome instanceof GatewayError,
|
|
`expected a GatewayError, got ${String(outcome)}`,
|
|
);
|
|
assertEquals(outcome.status, 413);
|
|
await req.body?.cancel();
|
|
});
|
|
|
|
Deno.test("readCappedText rejects an over-cap declared content-length before reading", async () => {
|
|
const req = new Request("http://gateway.test/v1/chat/completions", {
|
|
method: "POST",
|
|
headers: { "content-length": "9999" },
|
|
body: "x".repeat(9999),
|
|
});
|
|
const err = await assertRejects(() => readCappedText(req, 16), GatewayError);
|
|
assertEquals(err.status, 413);
|
|
// The body was never consumed: the declared length alone refused it.
|
|
assertEquals(req.bodyUsed, false);
|
|
});
|
|
|
|
Deno.test("readCappedText returns an empty string for a body-less request", async () => {
|
|
assertEquals(
|
|
await readCappedText(new Request("http://gateway.test/x"), 64),
|
|
"",
|
|
);
|
|
});
|
|
|
|
Deno.test("readCappedText decodes multibyte UTF-8 split across chunks", async () => {
|
|
// EUR is E2 82 AC; the split lands mid-character. The read must merge before
|
|
// decoding, or the caller gets replacement characters.
|
|
const req = new Request("http://gateway.test/x", {
|
|
method: "POST",
|
|
body: streamOf(new Uint8Array([0xE2, 0x82]), new Uint8Array([0xAC])),
|
|
});
|
|
assertEquals(await readCappedText(req, 64), "€");
|
|
});
|
|
|
|
Deno.test("dispatchThenDetach: an already-aborted request never reaches the provider (D1-T30)", async () => {
|
|
let upstreamHits = 0;
|
|
const server = Deno.serve({ port: 0, onListen: () => {} }, () => {
|
|
upstreamHits++;
|
|
return new Response("{}", {
|
|
headers: { "content-type": "application/json" },
|
|
});
|
|
});
|
|
try {
|
|
const url = `http://127.0.0.1:${server.addr.port}/images/generations`;
|
|
const controller = new AbortController();
|
|
const req = new Request(url, { method: "POST", signal: controller.signal });
|
|
const reason = new Error("client vanished before the route body ran");
|
|
controller.abort(reason);
|
|
|
|
let seen: AbortSignal | undefined;
|
|
let thrown: unknown;
|
|
try {
|
|
await dispatchThenDetach(req, (signal) => {
|
|
seen = signal;
|
|
return fetch(url, { method: "POST", signal });
|
|
});
|
|
} catch (error) {
|
|
thrown = error;
|
|
}
|
|
|
|
assert(seen, "dispatch was never invoked");
|
|
// Without the pre-listener guard the provider signal is live, the fetch
|
|
// succeeds, and a fully abandoned request costs full provider spend.
|
|
assertEquals(seen.aborted, true);
|
|
assertStrictEquals(seen.reason, reason);
|
|
assertStrictEquals(thrown, reason);
|
|
await withinMs(200, new Promise<void>(() => {})); // let a stray connection land
|
|
assertEquals(upstreamHits, 0);
|
|
} finally {
|
|
await server.shutdown();
|
|
}
|
|
});
|
|
|
|
Deno.test("dispatchThenDetach: an abort during the dispatch reaches the provider signal", async () => {
|
|
const controller = new AbortController();
|
|
const req = new Request("http://gateway.test/p", {
|
|
signal: controller.signal,
|
|
});
|
|
const reason = new Error("mid-flight");
|
|
let seenReason: unknown;
|
|
const res = await dispatchThenDetach(
|
|
req,
|
|
(signal) =>
|
|
new Promise<Response>((resolve) => {
|
|
signal.addEventListener("abort", () => {
|
|
seenReason = signal.reason;
|
|
resolve(new Response("abort-observed"));
|
|
}, { once: true });
|
|
controller.abort(reason);
|
|
}),
|
|
);
|
|
assertEquals(await res.text(), "abort-observed");
|
|
assertStrictEquals(seenReason, reason);
|
|
});
|
|
|
|
Deno.test("dispatchThenDetach: a client abort after the dispatch settles does not reach the provider signal", async () => {
|
|
const controller = new AbortController();
|
|
const req = new Request("http://gateway.test/q", {
|
|
signal: controller.signal,
|
|
});
|
|
let seen: AbortSignal | undefined;
|
|
const res = await dispatchThenDetach(req, (signal) => {
|
|
seen = signal;
|
|
return Promise.resolve(new Response("ok"));
|
|
});
|
|
assertEquals(seen!.aborted, false);
|
|
// From the return onward the body read is the gateway's obligation, not the
|
|
// client's option: the listener is gone, so this abort is not forwarded.
|
|
controller.abort(new Error("too late"));
|
|
assertEquals(seen!.aborted, false);
|
|
assertEquals(req.signal.aborted, true);
|
|
assertEquals(await res.text(), "ok");
|
|
});
|
|
|
|
/** ~15 arbitrary provider headers, none of which describes the body's framing. */
|
|
const PROVIDER_HEADERS: Record<string, string> = {
|
|
"content-type": "application/json",
|
|
"openai-organization": "org-abc",
|
|
"openai-processing-ms": "4821",
|
|
"openai-version": "2020-10-01",
|
|
"x-request-id": "req_upstream_7",
|
|
"x-ratelimit-limit-images": "50",
|
|
"x-ratelimit-remaining-images": "42",
|
|
"x-ratelimit-reset-images": "1s",
|
|
"x-ratelimit-limit-requests": "5000",
|
|
"x-ratelimit-remaining-requests": "4999",
|
|
"cf-ray": "8f2b1c-DFW",
|
|
"cf-cache-status": "DYNAMIC",
|
|
"x-envoy-upstream-service-time": "913",
|
|
"vary": "Accept-Encoding",
|
|
"etag": 'W/"9a1f"',
|
|
"x-vendor-quota-remaining": "17",
|
|
};
|
|
|
|
Deno.test("rebuild drops the framing headers and keeps every provider header (D1-T22(c))", async () => {
|
|
// Route level, no middleware in the chain: this pins rebuild()'s OWN
|
|
// invariant - a buffered body must not carry another body's framing headers.
|
|
const payload = JSON.stringify({
|
|
data: [{ b64_json: "AAAA" }, { b64_json: "BBBB" }],
|
|
});
|
|
const bytes = new TextEncoder().encode(payload);
|
|
const upstream = new Response("not the body we rebuild around", {
|
|
status: 200,
|
|
statusText: "OK",
|
|
headers: {
|
|
...PROVIDER_HEADERS,
|
|
"content-encoding": "gzip",
|
|
"content-length": "166",
|
|
"transfer-encoding": "chunked",
|
|
},
|
|
});
|
|
|
|
const out = rebuild(upstream, bytes);
|
|
|
|
assertEquals(out.status, 200);
|
|
for (const name of REBUILT_BODY_HEADERS) {
|
|
assertEquals(out.headers.get(name), null, `${name} must not survive`);
|
|
}
|
|
// Behavioural floor, independent of the shared set's contents.
|
|
assertEquals(out.headers.get("content-encoding"), null);
|
|
assertEquals(out.headers.get("content-length"), null);
|
|
assertEquals(out.headers.get("transfer-encoding"), null);
|
|
// Every provider header arrives with its exact value - the denylist adds
|
|
// nothing and drops nothing else. This is the set-membership residual,
|
|
// closed behaviourally.
|
|
for (const [name, value] of Object.entries(PROVIDER_HEADERS)) {
|
|
assertEquals(out.headers.get(name), value, `${name} must survive`);
|
|
}
|
|
assertEquals(
|
|
[...out.headers.keys()].length,
|
|
Object.keys(PROVIDER_HEADERS).length,
|
|
);
|
|
assertEquals(await out.text(), payload);
|
|
});
|
|
|
|
Deno.test("rebuild carries the gateway's own cache markers across the buffered body", async () => {
|
|
const bytes = new TextEncoder().encode("{}");
|
|
const upstream = new Response("stale", {
|
|
status: 200,
|
|
headers: {
|
|
"content-type": "application/json",
|
|
"content-length": "166",
|
|
"x-frosty-cache": "HIT",
|
|
"x-frosty-cache-type": "semantic",
|
|
},
|
|
});
|
|
const out = rebuild(upstream, bytes);
|
|
assertEquals(out.headers.get("x-frosty-cache"), "HIT");
|
|
assertEquals(out.headers.get("x-frosty-cache-type"), "semantic");
|
|
assertEquals(out.headers.get("content-length"), null);
|
|
assertEquals(await out.text(), "{}");
|
|
});
|