Files
openclaw/src/gateway/http-common.ts
Lellansin Huang aad3bbebdd fix: abort HTTP gateway turns on client disconnect (#54388) (thanks @Lellansin)
* fix: abort in-flight HTTP requests on client disconnect

Abort running agent commands when the HTTP client disconnects for both
/v1/chat/completions and /v1/responses endpoints.

- Listen on res "close" instead of req "close" (the request body is
  already consumed so IncomingMessage auto-destroys before we get here).
- Non-streaming: guard with !signal.aborted so the abort fires on
  genuine disconnects; a spurious abort after sendJson is harmless.
- Streaming: guard with !closed so normal res.end() completions do not
  abort post-turn work still in flight.
- Skip error logging and response writes when the signal is already
  aborted.

Made-with: Cursor

* fix: correct event listener name and improve error handling in HTTP requests

Updated the event listener for client disconnects to use the correct name and enhanced error handling logic. The changes ensure that abort signals are properly checked before logging errors and returning responses, preventing unnecessary operations on aborted requests.

Made-with: Cursor

* fix: use correct 'close' event name for non-streaming disconnect handler

* fix: watch socket close for HTTP aborts

---------

Co-authored-by: 冰森 <dingheng.huang@urbanic.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
2026-04-07 11:16:54 +05:30

141 lines
4.1 KiB
TypeScript

import type { IncomingMessage, ServerResponse } from "node:http";
import type { GatewayAuthResult } from "./auth.js";
import { readJsonBody } from "./hooks.js";
/**
* Apply baseline security headers that are safe for all response types (API JSON,
* HTML pages, static assets, SSE streams). Headers that restrict framing or set a
* Content-Security-Policy are intentionally omitted here because some handlers
* (canvas host, A2UI) serve content that may be loaded inside frames.
*/
export function setDefaultSecurityHeaders(
res: ServerResponse,
opts?: { strictTransportSecurity?: string },
) {
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Referrer-Policy", "no-referrer");
res.setHeader("Permissions-Policy", "camera=(), microphone=(), geolocation=()");
const strictTransportSecurity = opts?.strictTransportSecurity;
if (typeof strictTransportSecurity === "string" && strictTransportSecurity.length > 0) {
res.setHeader("Strict-Transport-Security", strictTransportSecurity);
}
}
export function sendJson(res: ServerResponse, status: number, body: unknown) {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(body));
}
export function sendText(res: ServerResponse, status: number, body: string) {
res.statusCode = status;
res.setHeader("Content-Type", "text/plain; charset=utf-8");
res.end(body);
}
export function sendMethodNotAllowed(res: ServerResponse, allow = "POST") {
res.setHeader("Allow", allow);
sendText(res, 405, "Method Not Allowed");
}
export function sendUnauthorized(res: ServerResponse) {
sendJson(res, 401, {
error: { message: "Unauthorized", type: "unauthorized" },
});
}
export function sendRateLimited(res: ServerResponse, retryAfterMs?: number) {
if (retryAfterMs && retryAfterMs > 0) {
res.setHeader("Retry-After", String(Math.ceil(retryAfterMs / 1000)));
}
sendJson(res, 429, {
error: {
message: "Too many failed authentication attempts. Please try again later.",
type: "rate_limited",
},
});
}
export function sendGatewayAuthFailure(res: ServerResponse, authResult: GatewayAuthResult) {
if (authResult.rateLimited) {
sendRateLimited(res, authResult.retryAfterMs);
return;
}
sendUnauthorized(res);
}
export function sendInvalidRequest(res: ServerResponse, message: string) {
sendJson(res, 400, {
error: { message, type: "invalid_request_error" },
});
}
export async function readJsonBodyOrError(
req: IncomingMessage,
res: ServerResponse,
maxBytes: number,
): Promise<unknown> {
const body = await readJsonBody(req, maxBytes);
if (!body.ok) {
if (body.error === "payload too large") {
sendJson(res, 413, {
error: { message: "Payload too large", type: "invalid_request_error" },
});
return undefined;
}
if (body.error === "request body timeout") {
sendJson(res, 408, {
error: { message: "Request body timeout", type: "invalid_request_error" },
});
return undefined;
}
sendInvalidRequest(res, body.error);
return undefined;
}
return body.value;
}
export function writeDone(res: ServerResponse) {
res.write("data: [DONE]\n\n");
}
export function setSseHeaders(res: ServerResponse) {
res.statusCode = 200;
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.flushHeaders?.();
}
export function watchClientDisconnect(
req: IncomingMessage,
res: ServerResponse,
abortController: AbortController,
onDisconnect?: () => void,
) {
const sockets = Array.from(
new Set(
[req.socket, res.socket].filter(
(socket): socket is NonNullable<typeof socket> => socket !== null,
),
),
);
if (sockets.length === 0) {
return () => {};
}
const handleClose = () => {
onDisconnect?.();
if (!abortController.signal.aborted) {
abortController.abort();
}
};
for (const socket of sockets) {
socket.on("close", handleClose);
}
return () => {
for (const socket of sockets) {
socket.off("close", handleClose);
}
};
}