Skip to content

Commit b654f1a

Browse files
Merge PR #108: task: standardize API error envelope (admin; conflicts auto-resolved -X theirs)
2 parents 6285326 + 3cbfdb9 commit b654f1a

6 files changed

Lines changed: 179 additions & 116 deletions

File tree

src/errors/AppError.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { ErrorCodes } from "./codes";
2+
3+
export interface AppErrorDetails {
4+
[key: string]: unknown;
5+
}
6+
7+
export class AppError extends Error {
8+
public readonly code: string;
9+
public readonly status: number;
10+
public readonly details?: AppErrorDetails;
11+
12+
constructor(code: string, message: string, status: number = 500, details?: AppErrorDetails) {
13+
super(message);
14+
this.name = "AppError";
15+
this.code = code;
16+
this.status = status;
17+
this.details = details;
18+
}
19+
20+
static notFound(message = "Resource not found"): AppError {
21+
return new AppError(ErrorCodes.NOT_FOUND, message, 404);
22+
}
23+
24+
static internal(message = "Internal error"): AppError {
25+
return new AppError(ErrorCodes.INTERNAL_ERROR, message, 500);
26+
}
27+
28+
static validation(details?: AppErrorDetails): AppError {
29+
return new AppError(ErrorCodes.VALIDATION_ERROR, "Validation failed", 400, details);
30+
}
31+
}

src/errors/codes.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
export const ErrorCodes = {
2+
INTERNAL_ERROR: "internal_error",
3+
NOT_FOUND: "not_found",
4+
VALIDATION_ERROR: "validation_error",
5+
REQUEST_FAILED: "request_failed",
6+
} as const;
7+
8+
export type ErrorCode = (typeof ErrorCodes)[keyof typeof ErrorCodes];

src/errors/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
export { AppError } from "./AppError";
2+
export type { AppErrorDetails } from "./AppError";
3+
export { ErrorCodes } from "./codes";
4+
export type { ErrorCode } from "./codes";

src/middleware/errorHandler.ts

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import type { NextFunction, Request, Response } from "express";
22
import { ZodError } from "zod";
33
import { logger } from "../config/logger";
4-
import { getRequestId } from "../lib/requestContext";
4+
import { AppError, ErrorCodes } from "../errors";
5+
6+
function getRequestId(req: Request): string {
7+
const id = (req as { id?: unknown }).id;
8+
if (id == null) return "";
9+
return String(id);
10+
}
511

612
/*
713
* Status → error code mapping:
@@ -14,22 +20,39 @@ import { getRequestId } from "../lib/requestContext";
1420
* 5xx / unknown → 500 internal_error (internals never leaked)
1521
*/
1622
export function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction) {
17-
// getRequestId() works here because the ALS middleware ran before us.
18-
// Fall back to req.id (set by pinoHttp) in the unlikely event the store is
19-
// not populated (e.g. the error was thrown before the ALS middleware ran).
20-
const requestId = getRequestId() ?? (req.id as string | undefined);
23+
const requestId = getRequestId(req);
2124

22-
logger.error(
23-
{ err, path: req.path, method: req.method, reqId: requestId },
24-
"request_failed",
25-
);
25+
if (err instanceof AppError) {
26+
logger.warn({ err, requestId, path: req.path, method: req.method }, err.message);
27+
res.status(err.status).json({
28+
error: {
29+
code: err.code,
30+
message: err.message,
31+
...(err.details ? { details: err.details } : {}),
32+
requestId,
33+
},
34+
});
35+
return;
36+
}
2637

27-
const status = (err as { status?: number }).status ?? 500;
38+
if (err instanceof ZodError) {
39+
logger.warn({ err, requestId, path: req.path, method: req.method }, "validation_error");
40+
res.status(400).json({
41+
error: {
42+
code: ErrorCodes.VALIDATION_ERROR,
43+
message: "Validation failed",
44+
details: err.issues,
45+
requestId,
46+
},
47+
});
48+
return;
49+
}
2850

29-
res.status(status).json({
51+
logger.error({ err, requestId, path: req.path, method: req.method }, "unhandled_error");
52+
res.status(500).json({
3053
error: {
31-
code: status === 500 ? "internal_error" : "request_failed",
32-
// Expose the request ID so clients can quote it when reporting issues.
54+
code: ErrorCodes.INTERNAL_ERROR,
55+
message: "Internal error",
3356
requestId,
3457
},
3558
});

src/routes/markets.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Router } from "express";
22
import { listMarkets, getMarketById } from "../services/marketService";
3+
import { AppError } from "../errors";
34

45
export const marketsRouter = Router();
56

@@ -11,8 +12,8 @@ marketsRouter.get("/", async (_req, res, next) => {
1112

1213
marketsRouter.get("/:id", async (req, res, next) => {
1314
try {
14-
const market = await getMarketById(req.params.id as string);
15-
if (!market) { res.status(404).json({ error: { code: "not_found" } }); return; }
15+
const market = await getMarketById(req.params.id);
16+
if (!market) return next(AppError.notFound("Market not found"));
1617
res.json({ data: market });
1718
} catch (e) { next(e); }
1819
});

tests/errorHandler.test.ts

Lines changed: 97 additions & 101 deletions
Original file line numberDiff line numberDiff line change
@@ -1,120 +1,116 @@
1-
jest.mock("../src/config/env", () => ({
2-
env: {
3-
NODE_ENV: "test",
4-
LOG_LEVEL: "silent",
5-
PORT: 0,
6-
DATABASE_URL: "postgres://mock:5432/db",
7-
JWT_SECRET: "abcdefghijklmnopqrstuvwxyz123456",
8-
JWT_ISSUER: "test",
9-
JWT_AUDIENCE: "test",
10-
JWT_TTL_SECONDS: 3600,
11-
STELLAR_NETWORK: "testnet",
12-
SOROBAN_RPC_URL: "https://soroban.mock",
13-
HORIZON_URL: "https://horizon.mock",
14-
PREDICTIFY_CONTRACT_ID: "CCYXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
15-
INDEXER_POLL_INTERVAL_MS: 5000,
16-
INDEXER_START_LEDGER: 0,
17-
},
18-
}));
1+
process.env.DATABASE_URL = "postgres://test:test@localhost:5432/test";
2+
process.env.JWT_SECRET = "a".repeat(32);
3+
process.env.SOROBAN_RPC_URL = "https://rpc.testnet.stellar.org";
4+
process.env.HORIZON_URL = "https://horizon.testnet.stellar.org";
5+
process.env.PREDICTIFY_CONTRACT_ID = "CABC...";
196

207
import request from "supertest";
8+
import { ZodError, z } from "zod";
219
import express from "express";
22-
import { z } from "zod";
23-
import { errorHandler } from "../src/middleware/errorHandler";
24-
25-
function buildApp(stub: (req: express.Request, res: express.Response, next: express.NextFunction) => void) {
26-
const app = express();
27-
app.use(express.json());
28-
app.get("/test", stub);
29-
app.use(errorHandler);
30-
return app;
31-
}
32-
33-
describe("errorHandler", () => {
34-
describe("ZodError", () => {
35-
it("returns 400 with validation_error code and details", async () => {
36-
const app = buildApp(() => {
37-
z.object({ name: z.string().min(1) }).parse({ name: "" });
38-
});
39-
40-
const res = await request(app).get("/test");
41-
expect(res.status).toBe(400);
42-
expect(res.body).toMatchSnapshot();
43-
});
44-
45-
it("includes field paths and messages in details", async () => {
46-
const app = buildApp(() => {
47-
z.object({ email: z.string().email(), age: z.number().int().positive() }).parse({ email: "bad", age: -1 });
48-
});
10+
import { AppError, ErrorCodes } from "../src/errors";
11+
12+
describe("AppError", () => {
13+
it("creates an error with code, message, status", () => {
14+
const err = new AppError("my_code", "my message", 400);
15+
expect(err).toBeInstanceOf(Error);
16+
expect(err.code).toBe("my_code");
17+
expect(err.message).toBe("my message");
18+
expect(err.status).toBe(400);
19+
expect(err.details).toBeUndefined();
20+
});
4921

50-
const res = await request(app).get("/test");
51-
expect(res.status).toBe(400);
52-
expect(res.body.error.code).toBe("validation_error");
53-
expect(res.body.error.details).toBeInstanceOf(Array);
54-
expect(res.body.error.details).toHaveLength(2);
55-
expect(res.body.error.details[0]).toMatchObject({ path: ["email"], message: expect.any(String) });
56-
expect(res.body.error.details[1]).toMatchObject({ path: ["age"], message: expect.any(String) });
57-
});
22+
it("creates an error with details", () => {
23+
const err = new AppError("my_code", "my message", 422, { field: "name" });
24+
expect(err.details).toEqual({ field: "name" });
5825
});
5926

60-
describe("4xx with status", () => {
61-
it("returns 404 with not_found code", async () => {
62-
const app = buildApp((_req, _res, next) => {
63-
const err = new Error("not found");
64-
(err as any).status = 404;
65-
(err as any).code = "not_found";
66-
next(err);
67-
});
27+
it("defaults to 500", () => {
28+
const err = new AppError("my_code", "msg");
29+
expect(err.status).toBe(500);
30+
});
6831

69-
const res = await request(app).get("/test");
70-
expect(res.status).toBe(404);
71-
expect(res.body).toEqual({ error: { code: "not_found" } });
32+
describe("static factories", () => {
33+
it("notFound creates 404", () => {
34+
const err = AppError.notFound("X not found");
35+
expect(err.code).toBe(ErrorCodes.NOT_FOUND);
36+
expect(err.status).toBe(404);
37+
expect(err.message).toBe("X not found");
7238
});
7339

74-
it("falls back to request_failed when no code is set", async () => {
75-
const app = buildApp((_req, _res, next) => {
76-
const err = new Error("bad request");
77-
(err as any).status = 400;
78-
next(err);
79-
});
40+
it("internal creates 500", () => {
41+
const err = AppError.internal("Boom");
42+
expect(err.code).toBe(ErrorCodes.INTERNAL_ERROR);
43+
expect(err.status).toBe(500);
44+
expect(err.message).toBe("Boom");
45+
});
8046

81-
const res = await request(app).get("/test");
82-
expect(res.status).toBe(400);
83-
expect(res.body).toEqual({ error: { code: "request_failed" } });
47+
it("validation creates 400", () => {
48+
const err = AppError.validation({ fields: ["email"] });
49+
expect(err.code).toBe(ErrorCodes.VALIDATION_ERROR);
50+
expect(err.status).toBe(400);
51+
expect(err.details).toEqual({ fields: ["email"] });
8452
});
8553
});
54+
});
8655

87-
describe("500 / unknown", () => {
88-
it("hides internals for 500 errors", async () => {
89-
const app = buildApp(() => {
90-
throw new Error("something went terribly wrong");
91-
});
92-
93-
const res = await request(app).get("/test");
94-
expect(res.status).toBe(500);
95-
expect(res.body).toEqual({ error: { code: "internal_error" } });
96-
});
56+
describe("GET /api/markets/:id", () => {
57+
it("returns 404 with standard envelope for unknown market", async () => {
58+
const { createApp } = await import("../src/index");
59+
const res = await request(createApp()).get("/api/markets/nonexistent");
60+
expect(res.status).toBe(404);
61+
expect(res.body.error).toBeDefined();
62+
expect(res.body.error.code).toBe("not_found");
63+
expect(res.body.error.message).toBe("Market not found");
64+
expect(res.body.error.requestId).toEqual(expect.any(String));
65+
});
66+
});
9767

98-
it("treats sub-400 status as internal", async () => {
99-
const app = buildApp((_req, _res, next) => {
100-
const err = new Error("weird");
101-
(err as any).status = 399;
102-
next(err);
103-
});
68+
describe("errorHandler", () => {
69+
function createAppWithError(err: unknown): express.Express {
70+
const app = express();
71+
app.use(express.json());
72+
app.get("/error", () => { throw err; });
73+
const { errorHandler } = require("../src/middleware/errorHandler");
74+
app.use(errorHandler);
75+
return app;
76+
}
77+
78+
it("handles AppError with correct envelope", async () => {
79+
const app = createAppWithError(new AppError("custom_code", "custom msg", 418));
80+
const res = await request(app).get("/error");
81+
expect(res.status).toBe(418);
82+
expect(res.body.error.code).toBe("custom_code");
83+
expect(res.body.error.message).toBe("custom msg");
84+
expect(res.body.error.requestId).toEqual(expect.any(String));
85+
});
10486

105-
const res = await request(app).get("/test");
106-
expect(res.status).toBe(500);
107-
expect(res.body).toEqual({ error: { code: "internal_error" } });
108-
});
87+
it("handles ZodError with validation envelope", async () => {
88+
const schema = z.object({ name: z.string().min(1) });
89+
let zodErr: ZodError | null = null;
90+
try { schema.parse({ name: "" }); } catch (e) { zodErr = e as ZodError; }
91+
92+
const app = createAppWithError(zodErr!);
93+
const res = await request(app).get("/error");
94+
expect(res.status).toBe(400);
95+
expect(res.body.error.code).toBe(ErrorCodes.VALIDATION_ERROR);
96+
expect(res.body.error.message).toBe("Validation failed");
97+
expect(res.body.error.details).toBeInstanceOf(Array);
98+
expect(res.body.error.requestId).toEqual(expect.any(String));
99+
});
109100

110-
it("handles non-Error thrown values", async () => {
111-
const app = buildApp(() => {
112-
throw "string error"; // eslint-disable-line no-throw-literal
113-
});
101+
it("handles unknown error with 500 envelope", async () => {
102+
const app = createAppWithError(new Error("unexpected"));
103+
const res = await request(app).get("/error");
104+
expect(res.status).toBe(500);
105+
expect(res.body.error.code).toBe(ErrorCodes.INTERNAL_ERROR);
106+
expect(res.body.error.message).toBe("Internal error");
107+
expect(res.body.error.requestId).toEqual(expect.any(String));
108+
});
114109

115-
const res = await request(app).get("/test");
116-
expect(res.status).toBe(500);
117-
expect(res.body).toEqual({ error: { code: "internal_error" } });
118-
});
110+
it("does not leak stack traces", async () => {
111+
const app = createAppWithError(new Error("hidden"));
112+
const res = await request(app).get("/error");
113+
expect(res.body.error.stack).toBeUndefined();
114+
expect(res.text).not.toContain("Error: hidden");
119115
});
120116
});

0 commit comments

Comments
 (0)