Skip to content

Commit 41acd52

Browse files
authored
feat: improve TRPC error propagation (#129)
1 parent f022688 commit 41acd52

File tree

8 files changed

+596
-437
lines changed

8 files changed

+596
-437
lines changed

src/app/api/trpc/[trpc]/route.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,47 @@
11
import { fetchRequestHandler } from "@trpc/server/adapters/fetch";
2+
import { TRPC_ERROR_CODES_BY_KEY } from "@trpc/server/rpc";
23
import type { NextRequest } from "next/server";
34

45
import { appRouter } from "@/server/index";
56
import { createTRPCContext } from "@/server/trpc";
67

8+
// Create automatic mapping from TRPC error codes to HTTP status codes
9+
const TRPC_TO_HTTP_STATUS = {
10+
BAD_REQUEST: 400,
11+
UNAUTHORIZED: 401,
12+
FORBIDDEN: 403,
13+
NOT_FOUND: 404,
14+
METHOD_NOT_SUPPORTED: 405,
15+
TIMEOUT: 408,
16+
CONFLICT: 409,
17+
PRECONDITION_FAILED: 412,
18+
PAYLOAD_TOO_LARGE: 413,
19+
UNPROCESSABLE_CONTENT: 422,
20+
TOO_MANY_REQUESTS: 429,
21+
CLIENT_CLOSED_REQUEST: 499,
22+
INTERNAL_SERVER_ERROR: 500,
23+
} as const;
24+
25+
function getHttpStatusFromTrpcError(errorCode: number | string): number {
26+
if (typeof errorCode === "string" && errorCode in TRPC_TO_HTTP_STATUS) {
27+
return TRPC_TO_HTTP_STATUS[errorCode as keyof typeof TRPC_TO_HTTP_STATUS];
28+
}
29+
30+
if (typeof errorCode === "number") {
31+
for (const [trpcCode, jsonRpcCode] of Object.entries(
32+
TRPC_ERROR_CODES_BY_KEY,
33+
)) {
34+
if (jsonRpcCode === errorCode && trpcCode in TRPC_TO_HTTP_STATUS) {
35+
return TRPC_TO_HTTP_STATUS[
36+
trpcCode as keyof typeof TRPC_TO_HTTP_STATUS
37+
];
38+
}
39+
}
40+
}
41+
42+
return 500;
43+
}
44+
745
/**
846
* This wraps the `createTRPCContext` helper and provides the required context for the tRPC API when
947
* handling a HTTP request (e.g. when you make requests from Client Components).
@@ -29,6 +67,15 @@ const handler = (req: NextRequest) =>
2967
);
3068
}
3169
: undefined,
70+
responseMeta({ errors }) {
71+
if (errors.length > 0) {
72+
const firstError = errors[0];
73+
const httpStatus = getHttpStatusFromTrpcError(firstError.code);
74+
return { status: httpStatus };
75+
}
76+
77+
return { status: 200 };
78+
},
3279
});
3380

3481
export { handler as GET, handler as POST };

src/lib/errors.ts

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,78 @@
1+
import { TRPCError } from "@trpc/server";
2+
import type { TRPC_ERROR_CODE_KEY } from "@trpc/server/rpc";
3+
import { isAxiosError } from "axios";
4+
15
export class ResourceNotFoundError extends Error {
26
constructor(message: string) {
37
super(message);
48
this.name = "ResourceNotFoundError";
59
}
610
}
11+
12+
function mapStatusCodeToTRPCCode(statusCode: number): TRPC_ERROR_CODE_KEY {
13+
if (statusCode >= 400 && statusCode < 500) {
14+
switch (statusCode) {
15+
case 400:
16+
return "BAD_REQUEST";
17+
case 401:
18+
return "UNAUTHORIZED";
19+
case 403:
20+
return "FORBIDDEN";
21+
case 404:
22+
return "NOT_FOUND";
23+
case 409:
24+
return "CONFLICT";
25+
case 422:
26+
return "UNPROCESSABLE_CONTENT";
27+
case 429:
28+
return "TOO_MANY_REQUESTS";
29+
default:
30+
return "BAD_REQUEST";
31+
}
32+
}
33+
34+
return "INTERNAL_SERVER_ERROR";
35+
}
36+
37+
export function toTRPCError(error: unknown): TRPCError {
38+
if (error instanceof TRPCError) {
39+
return error;
40+
}
41+
42+
if (isAxiosError(error)) {
43+
const statusCode = error.response?.status || 500;
44+
const message = error.response?.data?.message || error.message;
45+
const code = mapStatusCodeToTRPCCode(statusCode);
46+
return new TRPCError({ code, message, cause: error });
47+
}
48+
49+
if (error instanceof ResourceNotFoundError) {
50+
return new TRPCError({
51+
code: "NOT_FOUND",
52+
message: error.message,
53+
cause: error,
54+
});
55+
}
56+
57+
// Handle regular Error objects with custom status codes
58+
if (error instanceof Error) {
59+
const errorWithStatus = error as Error & {
60+
status?: number;
61+
statusCode?: number;
62+
};
63+
const statusCode = errorWithStatus.status || errorWithStatus.statusCode;
64+
65+
if (statusCode) {
66+
const code = mapStatusCodeToTRPCCode(statusCode);
67+
return new TRPCError({ code, message: error.message, cause: error });
68+
}
69+
}
70+
71+
const message =
72+
error instanceof Error ? error.message : "Unknown error occurred";
73+
return new TRPCError({
74+
code: "INTERNAL_SERVER_ERROR",
75+
message,
76+
cause: error,
77+
});
78+
}

src/server/routers/compliance.ts

Lines changed: 6 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { apiClient } from "@/lib/axios";
22
import { PaymentDetailsStatus } from "@/lib/constants/bank-account";
33
import { Gender } from "@/lib/constants/compliance";
4+
import { toTRPCError } from "@/lib/errors";
45
import { bankAccountSchema } from "@/lib/schemas/bank-account";
56
import { complianceFormSchema } from "@/lib/schemas/compliance";
67
import { filterDefinedValues } from "@/lib/utils";
@@ -129,14 +130,7 @@ export const complianceRouter = router({
129130

130131
return { success: true };
131132
} catch (error) {
132-
console.error("Error updating agreement status:", error);
133-
throw new TRPCError({
134-
code: "INTERNAL_SERVER_ERROR",
135-
message:
136-
error instanceof Error
137-
? error.message
138-
: "Failed to update agreement status",
139-
});
133+
throw toTRPCError(error);
140134
}
141135
}),
142136

@@ -403,28 +397,7 @@ export const complianceRouter = router({
403397
throw error;
404398
}
405399

406-
// Map API client errors to appropriate error codes
407-
if (error instanceof AxiosError) {
408-
const status = error.response?.status;
409-
// Map client errors (400, 422) to BAD_REQUEST
410-
if (status === 400 || status === 422) {
411-
throw new TRPCError({
412-
code: "BAD_REQUEST",
413-
message:
414-
error.response?.data?.message ||
415-
"Invalid payment details provided",
416-
});
417-
}
418-
}
419-
420-
// Otherwise wrap it in a TRPCError
421-
throw new TRPCError({
422-
code: "INTERNAL_SERVER_ERROR",
423-
message:
424-
error instanceof Error
425-
? `Failed to allow payment details: ${error.message}`
426-
: "Failed to allow payment details",
427-
});
400+
throw toTRPCError(error);
428401
}
429402
}),
430403

@@ -496,13 +469,7 @@ export const complianceRouter = router({
496469
};
497470
} catch (error) {
498471
console.error("Error getting payment details:", error);
499-
throw new TRPCError({
500-
code: "INTERNAL_SERVER_ERROR",
501-
message:
502-
error instanceof Error
503-
? `Failed to retrieve payment details: ${error.message}`
504-
: "Failed to retrieve payment details",
505-
});
472+
throw toTRPCError(error);
506473
}
507474
}),
508475

@@ -563,13 +530,7 @@ export const complianceRouter = router({
563530
throw error;
564531
}
565532

566-
throw new TRPCError({
567-
code: "INTERNAL_SERVER_ERROR",
568-
message:
569-
error instanceof Error
570-
? `Failed to retrieve payment details by ID: ${error.message}`
571-
: "Failed to retrieve payment details by ID",
572-
});
533+
throw toTRPCError(error);
573534
}
574535
}),
575536

@@ -602,13 +563,7 @@ export const complianceRouter = router({
602563
}
603564

604565
console.error("Error finding user by email:", error);
605-
throw new TRPCError({
606-
code: "INTERNAL_SERVER_ERROR",
607-
message:
608-
error instanceof Error
609-
? `Failed to find user by email: ${error.message}`
610-
: "Failed to find user by email",
611-
});
566+
throw toTRPCError(error);
612567
}
613568
}),
614569
});

src/server/routers/invoice-me.ts

Lines changed: 62 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { toTRPCError } from "@/lib/errors";
12
import { TRPCError } from "@trpc/server";
23
import { and, desc, eq } from "drizzle-orm";
34
import { ulid } from "ulid";
@@ -14,19 +15,22 @@ export const invoiceMeRouter = router({
1415
)
1516
.mutation(async ({ ctx, input }) => {
1617
const { db, user } = ctx;
18+
try {
19+
if (!user) {
20+
throw new TRPCError({
21+
code: "UNAUTHORIZED",
22+
message: "You must be logged in to create an invoice me link",
23+
});
24+
}
1725

18-
if (!user) {
19-
throw new TRPCError({
20-
code: "UNAUTHORIZED",
21-
message: "You must be logged in to create an invoice me link",
26+
await db.insert(invoiceMeTable).values({
27+
id: ulid(),
28+
label: input.label,
29+
userId: user.id,
2230
});
31+
} catch (error) {
32+
throw toTRPCError(error);
2333
}
24-
25-
await db.insert(invoiceMeTable).values({
26-
id: ulid(),
27-
label: input.label,
28-
userId: user.id,
29-
});
3034
}),
3135
getAll: protectedProcedure.query(async ({ ctx }) => {
3236
const { db, user } = ctx;
@@ -37,55 +41,69 @@ export const invoiceMeRouter = router({
3741
message: "You must be logged in to get your invoice me links",
3842
});
3943
}
44+
try {
45+
const invoiceMeLinks = await db.query.invoiceMeTable.findMany({
46+
where: eq(invoiceMeTable.userId, user.id),
47+
orderBy: desc(invoiceMeTable.createdAt),
48+
});
4049

41-
const invoiceMeLinks = await db.query.invoiceMeTable.findMany({
42-
where: eq(invoiceMeTable.userId, user.id),
43-
orderBy: desc(invoiceMeTable.createdAt),
44-
});
45-
46-
return invoiceMeLinks;
50+
return invoiceMeLinks;
51+
} catch (error) {
52+
throw toTRPCError(error);
53+
}
4754
}),
4855
delete: protectedProcedure
4956
.input(z.string())
5057
.mutation(async ({ ctx, input }) => {
5158
const { db, user } = ctx;
5259

53-
if (!user) {
54-
throw new TRPCError({
55-
code: "UNAUTHORIZED",
56-
message: "You must be logged in to delete an invoice me link",
57-
});
58-
}
60+
try {
61+
if (!user) {
62+
throw new TRPCError({
63+
code: "UNAUTHORIZED",
64+
message: "You must be logged in to delete an invoice me link",
65+
});
66+
}
5967

60-
await db
61-
.delete(invoiceMeTable)
62-
.where(
63-
and(eq(invoiceMeTable.id, input), eq(invoiceMeTable.userId, user.id)),
64-
);
68+
await db
69+
.delete(invoiceMeTable)
70+
.where(
71+
and(
72+
eq(invoiceMeTable.id, input),
73+
eq(invoiceMeTable.userId, user.id),
74+
),
75+
);
76+
} catch (error) {
77+
throw toTRPCError(error);
78+
}
6579
}),
6680
getById: publicProcedure.input(z.string()).query(async ({ ctx, input }) => {
6781
const { db } = ctx;
6882

69-
const invoiceMeLink = await db.query.invoiceMeTable.findFirst({
70-
where: eq(invoiceMeTable.id, input),
71-
with: {
72-
user: {
73-
columns: {
74-
name: true,
75-
email: true,
76-
id: true,
83+
try {
84+
const invoiceMeLink = await db.query.invoiceMeTable.findFirst({
85+
where: eq(invoiceMeTable.id, input),
86+
with: {
87+
user: {
88+
columns: {
89+
name: true,
90+
email: true,
91+
id: true,
92+
},
7793
},
7894
},
79-
},
80-
});
81-
82-
if (!invoiceMeLink) {
83-
throw new TRPCError({
84-
code: "NOT_FOUND",
85-
message: "Invoice me link not found",
8695
});
87-
}
8896

89-
return invoiceMeLink;
97+
if (!invoiceMeLink) {
98+
throw new TRPCError({
99+
code: "NOT_FOUND",
100+
message: "Invoice me link not found",
101+
});
102+
}
103+
104+
return invoiceMeLink;
105+
} catch (error) {
106+
throw toTRPCError(error);
107+
}
90108
}),
91109
});

0 commit comments

Comments
 (0)