Skip to content

Commit 3cbfdb9

Browse files
committed
task: standardize API error envelope
1 parent 05778cb commit 3cbfdb9

7 files changed

Lines changed: 210 additions & 5 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: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,49 @@
11
import type { NextFunction, Request, Response } from "express";
2+
import { ZodError } from "zod";
23
import { logger } from "../config/logger";
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+
}
311

412
export function errorHandler(err: unknown, req: Request, res: Response, _next: NextFunction) {
5-
logger.error({ err, path: req.path, method: req.method }, "request_failed");
6-
const status = (err as { status?: number }).status ?? 500;
7-
res.status(status).json({
8-
error: { code: status === 500 ? "internal_error" : "request_failed" },
13+
const requestId = getRequestId(req);
14+
15+
if (err instanceof AppError) {
16+
logger.warn({ err, requestId, path: req.path, method: req.method }, err.message);
17+
res.status(err.status).json({
18+
error: {
19+
code: err.code,
20+
message: err.message,
21+
...(err.details ? { details: err.details } : {}),
22+
requestId,
23+
},
24+
});
25+
return;
26+
}
27+
28+
if (err instanceof ZodError) {
29+
logger.warn({ err, requestId, path: req.path, method: req.method }, "validation_error");
30+
res.status(400).json({
31+
error: {
32+
code: ErrorCodes.VALIDATION_ERROR,
33+
message: "Validation failed",
34+
details: err.issues,
35+
requestId,
36+
},
37+
});
38+
return;
39+
}
40+
41+
logger.error({ err, requestId, path: req.path, method: req.method }, "unhandled_error");
42+
res.status(500).json({
43+
error: {
44+
code: ErrorCodes.INTERNAL_ERROR,
45+
message: "Internal error",
46+
requestId,
47+
},
948
});
1049
}

src/routes/markets.ts

Lines changed: 2 additions & 1 deletion
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

@@ -12,7 +13,7 @@ marketsRouter.get("/", async (_req, res, next) => {
1213
marketsRouter.get("/:id", async (req, res, next) => {
1314
try {
1415
const market = await getMarketById(req.params.id);
15-
if (!market) return res.status(404).json({ error: { code: "not_found" } });
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: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
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...";
6+
7+
import request from "supertest";
8+
import { ZodError, z } from "zod";
9+
import express from "express";
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+
});
21+
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" });
25+
});
26+
27+
it("defaults to 500", () => {
28+
const err = new AppError("my_code", "msg");
29+
expect(err.status).toBe(500);
30+
});
31+
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");
38+
});
39+
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+
});
46+
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"] });
52+
});
53+
});
54+
});
55+
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+
});
67+
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+
});
86+
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+
});
100+
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+
});
109+
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");
115+
});
116+
});

tests/health.test.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
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...";
6+
17
import request from "supertest";
28
import { createApp } from "../src/index";
39

0 commit comments

Comments
 (0)