Skip to content

Commit 240d350

Browse files
fix(gateway): finish plugin HTTP responses after post-header failures (#102125)
* fix(gateway): finish plugin HTTP responses after post-header failures * test(gateway): satisfy plugin HTTP regression lint * fix(gateway): skip ending destroyed plugin responses --------- Co-authored-by: Peter Steinberger <[email protected]>
1 parent 67be0cd commit 240d350

2 files changed

Lines changed: 99 additions & 1 deletion

File tree

src/gateway/server/plugins-http.test.ts

Lines changed: 97 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// Plugin HTTP routing tests cover route matching, gateway auth decisions, and upgrade dispatch.
2-
import type { IncomingMessage, ServerResponse } from "node:http";
2+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
33
import type { Duplex } from "node:stream";
44
import { afterEach, describe, expect, it, vi } from "vitest";
55
import { registerPluginHttpRoute } from "../../plugins/http-registry.js";
@@ -450,6 +450,102 @@ describe("createGatewayPluginRequestHandler", () => {
450450
expect(setHeader).toHaveBeenCalledWith("Content-Type", "text/plain; charset=utf-8");
451451
expect(end).toHaveBeenCalledWith("Internal Server Error");
452452
});
453+
454+
it("ends a plugin route response when the route throws after sending headers", async () => {
455+
const log = createPluginLog();
456+
const handler = createGatewayPluginRequestHandler({
457+
registry: createTestRegistry({
458+
httpRoutes: [
459+
createRoute({
460+
path: "/partial",
461+
handler: async (_req, res) => {
462+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
463+
res.write("partial");
464+
throw new Error("boom");
465+
},
466+
}),
467+
],
468+
}),
469+
log,
470+
});
471+
const server = createServer((req, res) => {
472+
void (async () => {
473+
const handled = await handler(req, res);
474+
if (!handled) {
475+
res.statusCode = 404;
476+
res.end("not found");
477+
}
478+
})();
479+
});
480+
481+
await new Promise<void>((resolve, reject) => {
482+
server.once("error", reject);
483+
server.listen(0, "127.0.0.1", () => resolve());
484+
});
485+
486+
const address = server.address();
487+
if (!address || typeof address === "string") {
488+
throw new Error("server did not bind to a TCP port");
489+
}
490+
const controller = new AbortController();
491+
let timeout: ReturnType<typeof setTimeout> | undefined;
492+
493+
try {
494+
const response = await fetch(`http://127.0.0.1:${address.port}/partial`, {
495+
signal: controller.signal,
496+
});
497+
const result = await Promise.race([
498+
response.text().then(
499+
(body) => ({ kind: "body" as const, body }),
500+
(err: unknown) => ({ kind: "error" as const, message: String(err) }),
501+
),
502+
new Promise<{ kind: "timeout" }>((resolve) => {
503+
timeout = setTimeout(() => {
504+
controller.abort();
505+
resolve({ kind: "timeout" });
506+
}, 250);
507+
}),
508+
]);
509+
510+
expect(response.status).toBe(200);
511+
expect(result).toEqual({ kind: "body", body: "partial" });
512+
expect(log.warn).toHaveBeenCalledWith("plugin http route failed (route): Error: boom");
513+
} finally {
514+
if (timeout) {
515+
clearTimeout(timeout);
516+
}
517+
await new Promise<void>((resolve) => {
518+
server.close(() => resolve());
519+
});
520+
}
521+
});
522+
523+
it("does not end a response the plugin already destroyed before throwing", async () => {
524+
const log = createPluginLog();
525+
const handler = createGatewayPluginRequestHandler({
526+
registry: createTestRegistry({
527+
httpRoutes: [
528+
createRoute({
529+
path: "/destroyed",
530+
handler: async (_req, res) => {
531+
Object.defineProperty(res, "headersSent", { value: true, configurable: true });
532+
res.destroy();
533+
throw new Error("boom");
534+
},
535+
}),
536+
],
537+
}),
538+
log,
539+
});
540+
const { res, end } = makeMockHttpResponse();
541+
542+
const handled = await handler({ url: "/destroyed" } as IncomingMessage, res);
543+
544+
expect(handled).toBe(true);
545+
expect(res.destroyed).toBe(true);
546+
expect(end).not.toHaveBeenCalled();
547+
expect(log.warn).toHaveBeenCalledWith("plugin http route failed (route): Error: boom");
548+
});
453549
});
454550

455551
describe("createGatewayPluginUpgradeHandler", () => {

src/gateway/server/plugins-http.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,8 @@ export function createGatewayPluginRequestHandler(params: {
201201
res.statusCode = 500;
202202
res.setHeader("Content-Type", "text/plain; charset=utf-8");
203203
res.end("Internal Server Error");
204+
} else if (!res.writableEnded && !res.destroyed) {
205+
res.end();
204206
}
205207
return true;
206208
}

0 commit comments

Comments
 (0)