Spaces:
Running
Running
File size: 4,896 Bytes
9fc7d8e | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 | import { Agent, createServer, request, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./handleTokenVerification.ts", () => ({
handleTokenVerification: async () => ({ shouldContinue: true }),
}));
/**
* The body cap answers mid-upload and drops the socket to stop reading. Only a
* real connection shows what that costs the caller's next request: a mocked
* req/res has no pooled connection to be dropped underneath it.
*
* Runs under the default jsdom environment on purpose. `node:http` works there,
* and a per-file environment pragma cannot be used to switch it: the global
* setup file `client/setupTests.ts` reaches for `window`, so the node
* environment fails at import. The two `*.integration.test.ts` files escape
* that through `vitest.integration.config.ts`, which declares no setup file,
* but `npm test` does not run that config.
*/
describe("internalApiEndpointServerHook over a real socket", () => {
let server: Server;
let port: number;
beforeEach(async () => {
// With no upstream configured, a body that survives the cap still ends in
// a deterministic 500, which is all the follow-up needs: a status, not an
// error.
vi.stubEnv("INTERNAL_OPENAI_COMPATIBLE_API_BASE_URL", undefined);
vi.stubEnv("INTERNAL_OPENAI_COMPATIBLE_API_KEY", undefined);
const { internalApiEndpointServerHook } = await import(
"./internalApiEndpointServerHook.ts"
);
server = createServer((req, res) => {
const middlewares = {
use: (
handler: (req: unknown, res: unknown, next: () => void) => void,
) =>
handler(req, res, () => {
res.statusCode = 404;
res.end();
}),
};
internalApiEndpointServerHook({ middlewares } as never);
});
await new Promise<void>((resolve) => server.listen(0, resolve));
port = (server.address() as AddressInfo).port;
});
afterEach(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
vi.unstubAllEnvs();
});
function post(agent: Agent, payload: string | Buffer) {
return new Promise<string>((resolve) => {
const req = request(
{
port,
method: "POST",
path: "/inference",
agent,
headers: { "Content-Type": "application/json" },
},
(res) => {
res.resume();
res.on("end", () => resolve(`status=${res.statusCode}`));
},
);
req.on("error", (error) =>
resolve(`error=${(error as NodeJS.ErrnoException).code}`),
);
req.end(payload);
});
}
// The body is written in chunks with a delay between them so the server's
// 413 lands while the client is still uploading. Sending the whole body in
// one `end()` call lands it all before the response, and the poisoned-socket
// bug stays hidden.
function postChunkedOversized(agent: Agent) {
return new Promise<string>((resolve) => {
const req = request(
{
port,
method: "POST",
path: "/inference",
agent,
headers: { "Content-Type": "application/json" },
},
(res) => {
res.resume();
res.on("end", () => resolve(`status=${res.statusCode}`));
},
);
// The socket dies mid-upload once the server drops it; that error is
// expected, not a failure.
req.on("error", () => {});
void (async () => {
const chunk = Buffer.alloc(16 * 1024, "x");
try {
for (let i = 0; i < 80; i++) {
await new Promise((r) => setTimeout(r, 15));
if (req.destroyed) break;
req.write(chunk);
}
req.end();
} catch {
// The server dropped the socket before the upload finished.
}
})();
});
}
it("refuses an oversized body with 413 without breaking the next request", async () => {
// 80 x 16 KiB = 1.25 MiB, just over the 1 MiB cap: the 413 fires on the
// 65th chunk, with 15 more still queued on the client.
const agent = new Agent({ keepAlive: true, maxSockets: 1 });
try {
expect(await postChunkedOversized(agent)).toBe("status=413");
// A fresh connection, because the 413 told the agent not to pool the one
// the server then dropped. Without that header the agent reuses the
// dropped socket and this is an ECONNRESET. A valid small body ends in
// the 500 for the missing upstream config, so a status at all proves the
// connection survived.
expect(
await post(
agent,
JSON.stringify({ messages: [{ role: "user", content: "hi" }] }),
),
).toBe("status=500");
} finally {
agent.destroy();
}
});
});
|