diff --git a/src/routes/tags.ts b/src/routes/tags.ts index 33faf8a5..f9e81c35 100644 --- a/src/routes/tags.ts +++ b/src/routes/tags.ts @@ -3,10 +3,21 @@ import { z } from "zod"; import { accessLog } from "../middleware/accessLog"; import { logger } from "../config/logger"; import { getMarketTags } from "../repositories/marketRepository"; +import { abortableRace, requestTimeout, RequestAbortedError } from "../middleware/timeout"; export const tagsRouter = Router(); tagsRouter.use(accessLog); +const TAGS_TIMEOUT_MS = 5000; + +tagsRouter.use( + requestTimeout(TAGS_TIMEOUT_MS, { + statusCode: 504, + code: "gateway_timeout", + message: "Tags request timed out", + }), +); + const tagsQuerySchema = z.object({ limit: z.coerce.number().int().min(1).max(100).default(10), }); @@ -16,7 +27,7 @@ const tagsQuerySchema = z.object({ * /api/tags: * get: * summary: Retrieve system tags - * description: Returns a list of tags. Used to test the tags access log. + * description: Returns a list of tags. Used to test the tags access log. The request is bounded by a per-request timeout and returns 504 if the backing read exceeds the deadline. * tags: * - Tags * parameters: @@ -46,9 +57,17 @@ const tagsQuerySchema = z.object({ * application/json: * schema: * $ref: '#/components/schemas/ErrorResponse' + * 504: + * description: Tags lookup exceeded the per-request timeout + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/ErrorResponse' */ tagsRouter.get("/", async (req: Request, res: Response, next: NextFunction) => { const reqId = String((req as Request & { id?: unknown }).id ?? "anon"); + const signal = res.locals.abortSignal as { aborted: boolean; addEventListener: (...args: unknown[]) => void; removeEventListener: (...args: unknown[]) => void } | undefined; + try { const parsed = tagsQuerySchema.safeParse(req.query); if (!parsed.success) { @@ -58,14 +77,22 @@ tagsRouter.get("/", async (req: Request, res: Response, next: NextFunction) => { const { limit } = parsed.data; logger.debug({ reqId, correlationId: reqId, limit }, "Fetching system tags"); - - const data = await getMarketTags(); + + const data = await abortableRace(getMarketTags(), signal); const tags = data.map((d) => d.tag).slice(0, limit); - + logger.info({ reqId, correlationId: reqId, count: tags.length }, "System tags fetched successfully"); - + res.json({ tags }); } catch (e) { + if (e instanceof RequestAbortedError) { + logger.warn( + { correlationId: reqId, path: req.path }, + "Abandoned /api/tags request after timeout", + ); + return; + } + logger.error({ reqId, correlationId: reqId, err: e }, "Failed to fetch system tags"); next(e); } diff --git a/tests/routes/tags.test.ts b/tests/routes/tags.test.ts index ad3fad63..cbd9447b 100644 --- a/tests/routes/tags.test.ts +++ b/tests/routes/tags.test.ts @@ -5,6 +5,14 @@ import { closeAuthPool } from "../../src/middleware/requireAuth"; import { getMarketTags } from "../../src/repositories/marketRepository"; import { errorHandler } from "../../src/middleware/errorHandler"; +jest.mock("../../src/middleware/timeout", () => { + const actual = jest.requireActual("../../src/middleware/timeout"); + return { + ...actual, + requestTimeout: actual.requestTimeout, + }; +}); + jest.mock("../../src/repositories/marketRepository"); describe("Tags API", () => { let app: any; @@ -18,6 +26,7 @@ describe("Tags API", () => { beforeEach(() => { jest.resetAllMocks(); + jest.useRealTimers(); (getMarketTags as jest.Mock).mockResolvedValue([ { tag: "stellar", count: 10 }, { tag: "wave", count: 5 }, @@ -70,4 +79,45 @@ describe("Tags API", () => { /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/, ); }); + + it("should return 504 when tag lookup exceeds the timeout", async () => { + jest.useFakeTimers(); + (getMarketTags as jest.Mock).mockImplementation( + () => new Promise((resolve) => setTimeout(() => resolve([{ tag: "slow", count: 1 }]), 6000)), + ); + + const responsePromise = request(app).get("/api/tags"); + await jest.advanceTimersByTimeAsync(5000); + const res = await responsePromise; + + expect(res.status).toBe(504); + expect(res.body.error).toMatchObject({ + code: "gateway_timeout", + message: "Tags request timed out", + requestId: expect.any(String), + }); + }); + + it("should abandon the handler result after timeout instead of sending a second response", async () => { + jest.useFakeTimers(); + + let resolveTags: ((value: Array<{ tag: string; count: number }>) => void) | undefined; + (getMarketTags as jest.Mock).mockImplementation( + () => new Promise((resolve) => { + resolveTags = resolve; + }), + ); + + const responsePromise = request(app).get("/api/tags"); + await jest.advanceTimersByTimeAsync(5000); + const res = await responsePromise; + + expect(res.status).toBe(504); + + resolveTags?.([{ tag: "late", count: 1 }]); + await Promise.resolve(); + await Promise.resolve(); + + expect(getMarketTags).toHaveBeenCalledTimes(1); + }); });