Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions src/middleware/timeout.ts
Original file line number Diff line number Diff line change
@@ -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();
};
}
6 changes: 6 additions & 0 deletions src/routes/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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
// ---------------------------------------------------------------------------
Expand Down
61 changes: 61 additions & 0 deletions tests/middleware/timeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { requestTimeout } from "../../src/middleware/timeout";
import { Request, Response, NextFunction } from "express";

describe("requestTimeout Middleware", () => {
let req: Partial<Request>;
let res: Partial<Response>;
let next: jest.Mock<NextFunction>;

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();
});
});