diff --git a/src/middleware/timeout.ts b/src/middleware/timeout.ts new file mode 100644 index 0000000..7e9afd0 --- /dev/null +++ b/src/middleware/timeout.ts @@ -0,0 +1,58 @@ +import { Request, Response, NextFunction } from "express"; +import { RouteErrorFactory } from "../errors"; + +/** + * Creates an Express middleware that enforces a maximum request duration. + * If the route handler does not finish before `timeoutMs`, the request is + * aborted gracefully with a 408 Request Timeout error envelope. + * + * @param timeoutMs - Time in milliseconds before the request times out + */ +export function requestTimeout(timeoutMs: number) { + return (req: Request, res: Response, next: NextFunction) => { + // We attach an abort controller to the response locals so that + // downstream services can optionally listen to res.locals.abortSignal + const controller = new AbortController(); + res.locals.abortSignal = controller.signal; + + // Handle client disconnects to cancel downstream work + req.on("close", () => { + controller.abort(); + }); + + const timer = setTimeout(() => { + controller.abort(); + + if (!res.headersSent) { + // Use RouteErrorFactory to throw a standardized error envelope. + // The global error handler or this block will send it out. + // To be safe and avoid double-calling next(), we can just send the error here, + // but it's cleaner to let the global error handler format it. + // Wait, if we call next() from a timeout, we have to ensure we don't + // clash with the active route handler. + // We will just directly respond to guarantee the 408 is sent. + const error = RouteErrorFactory.internal("Request timeout exceeded"); + // We change kind to RequestTimeout manually or just use 408 status. + // Since RouteErrorFactory doesn't have timeout, we can build a raw envelope. + const correlationId = (res.locals.correlationId as string) || "unknown"; + return res.status(408).json({ + error: { + code: "timeout", + message: "Request timeout exceeded", + requestId: correlationId, + } + }); + } + }, timeoutMs); + + res.on("finish", () => { + clearTimeout(timer); + }); + + res.on("close", () => { + clearTimeout(timer); + }); + + next(); + }; +} diff --git a/src/routes/users.ts b/src/routes/users.ts index b117a3d..200bcad 100644 --- a/src/routes/users.ts +++ b/src/routes/users.ts @@ -38,6 +38,7 @@ import { logger } from "../config/logger"; import { getRequestId } from "../lib/requestContext"; import { clampLimit, DEFAULT_PAGE_SIZE } from "../utils/cursor"; import { RouteErrorFactory } from "../errors"; +import { requestTimeout } from "../middleware/timeout"; export const usersRouter = Router(); @@ -52,6 +53,11 @@ const stellarAddressSchema = z // --------------------------------------------------------------------------- usersRouter.use(accessLog); +// --------------------------------------------------------------------------- +// Per-request timeout with graceful abort on /api/users +// --------------------------------------------------------------------------- +usersRouter.use(requestTimeout(15000)); // 15 seconds timeout + // --------------------------------------------------------------------------- // GET /api/users/me // --------------------------------------------------------------------------- diff --git a/tests/middleware/timeout.test.ts b/tests/middleware/timeout.test.ts new file mode 100644 index 0000000..d2bf85e --- /dev/null +++ b/tests/middleware/timeout.test.ts @@ -0,0 +1,61 @@ +import { requestTimeout } from "../../src/middleware/timeout"; +import { Request, Response, NextFunction } from "express"; + +describe("requestTimeout Middleware", () => { + let req: Partial; + let res: Partial; + let next: jest.Mock; + + beforeEach(() => { + jest.useFakeTimers(); + req = { + on: jest.fn(), + }; + res = { + locals: { correlationId: "test-req-id" }, + headersSent: false, + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + on: jest.fn(), + }; + next = jest.fn(); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("calls next()", () => { + const middleware = requestTimeout(1000); + middleware(req as Request, res as Response, next); + expect(next).toHaveBeenCalledTimes(1); + expect(res.locals?.abortSignal).toBeInstanceOf(AbortSignal); + }); + + it("sends 408 when timeout is exceeded and headers are not sent", () => { + const middleware = requestTimeout(1000); + middleware(req as Request, res as Response, next); + + jest.advanceTimersByTime(1000); + + expect(res.status).toHaveBeenCalledWith(408); + expect(res.json).toHaveBeenCalledWith({ + error: { + code: "timeout", + message: "Request timeout exceeded", + requestId: "test-req-id", + }, + }); + }); + + it("does not send 408 if headers are already sent", () => { + const middleware = requestTimeout(1000); + res.headersSent = true; + middleware(req as Request, res as Response, next); + + jest.advanceTimersByTime(1000); + + expect(res.status).not.toHaveBeenCalled(); + expect(res.json).not.toHaveBeenCalled(); + }); +});