documenso后端异常处理:全局错误捕获与响应
引言:异常处理的重要性与挑战
在现代Web应用开发中,后端系统的稳定性和可靠性直接影响用户体验和业务连续性。documenso作为一个文档管理系统,需要处理各种潜在错误场景,包括用户操作错误、数据验证失败、资源访问受限以及服务器内部错误等。一个健壮的异常处理机制不仅能够提高系统的容错能力,还能为开发人员提供清晰的调试信息,同时向用户返回友好且一致的错误响应。
本文将深入剖析documenso后端异常处理的实现方案,重点介绍全局错误捕获机制、错误分类体系、统一响应格式以及实际应用中的最佳实践。通过阅读本文,您将能够:
- 理解documenso后端异常处理的整体架构
- 掌握自定义错误类的设计与使用方法
- 学习如何实现跨层级的全局错误捕获
- 了解不同API层(REST、TRPC)的错误处理策略
- 学会设计用户友好的错误响应格式
一、异常处理架构概览
documenso采用分层架构设计,其异常处理机制也相应地分布在不同层级,形成了一个完整的错误捕获与响应链条。下图展示了异常从产生到最终响应的完整流程:
从架构上看,documenso的异常处理主要包含以下几个关键组件:
- 自定义错误类:基于业务需求定义的错误类型体系
- API层错误处理:Hono和TRPC框架的错误捕获机制
- 全局错误中间件:统一处理未捕获异常的中央枢纽
- 错误响应格式化:将错误信息转换为客户端友好的格式
- 前端错误边界:捕获并展示从后端传递的错误信息
这种多层次的异常处理架构确保了无论是预期的业务错误还是意外的系统异常,都能被妥善处理并返回一致的响应。
二、自定义错误体系设计
documenso定义了一套完整的自定义错误体系,以区分不同类型的错误并提供精确的错误信息。核心是AppError基类和一系列业务特定错误类。
2.1 AppError基类实现
AppError类是所有自定义错误的基类,位于packages/lib/errors/app-error.ts,提供了统一的错误处理接口和转换功能:
export enum AppErrorCode {
'ALREADY_EXISTS' = 'ALREADY_EXISTS',
'EXPIRED_CODE' = 'EXPIRED_CODE',
'INVALID_BODY' = 'INVALID_BODY',
'INVALID_REQUEST' = 'INVALID_REQUEST',
'LIMIT_EXCEEDED' = 'LIMIT_EXCEEDED',
'NOT_FOUND' = 'NOT_FOUND',
'NOT_SETUP' = 'NOT_SETUP',
'UNAUTHORIZED' = 'UNAUTHORIZED',
'UNKNOWN_ERROR' = 'UNKNOWN_ERROR',
// 其他错误码...
}
export class AppError extends Error {
code: string;
userMessage?: string;
statusCode?: number;
name = 'AppError';
constructor(errorCode: string, options?: {
message?: string;
userMessage?: string;
statusCode?: number;
}) {
super(options?.message || errorCode);
this.code = errorCode;
this.userMessage = options?.userMessage;
this.statusCode = options?.statusCode;
}
// 将错误转换为JSON格式
static toJSON(appError: AppError): {
code: string;
message?: string;
userMessage?: string;
statusCode?: number;
} {
const { code, message, userMessage, statusCode } = appError;
const data: any = { code };
if (message) data.message = message;
if (userMessage) data.userMessage = userMessage;
if (statusCode) data.statusCode = statusCode;
return data;
}
// 从未知错误解析为AppError
static parseError(error: any): AppError {
if (error instanceof AppError) return error;
// 处理TRPC错误
if (error?.name === 'TRPCClientError') {
const parsed = AppError.parseFromJSON(error.data?.appError);
return parsed || new AppError(AppErrorCode.UNKNOWN_ERROR, { message: error.message });
}
// 处理其他未知错误
return new AppError(
error?.code || AppErrorCode.UNKNOWN_ERROR,
{
message: error?.message,
userMessage: error?.userMessage,
statusCode: error?.statusCode
}
);
}
// 转换为REST API错误响应
static toRestAPIError(err: unknown): {
status: 400 | 401 | 404 | 500;
body: { message: string };
} {
const error = AppError.parseError(err);
const status = error.code === AppErrorCode.UNAUTHORIZED ? 401 :
error.code === AppErrorCode.NOT_FOUND ? 404 :
error.code === AppError.INVALID_REQUEST ? 400 : 500;
return {
status,
body: { message: status !== 500 ? error.message : 'Something went wrong' }
};
}
}
2.2 业务特定错误类
基于AppError,documenso实现了特定业务场景的错误类,例如UserExistsError:
// packages/lib/errors/user-exists.ts
export class UserExistsError extends Error {
constructor() {
super('User already exists');
}
}
这类错误通常用于特定业务逻辑中,如用户注册时检测到重复邮箱:
if (existingUser) {
throw new UserExistsError();
}
2.3 错误码与HTTP状态码映射
documenso定义了错误码到HTTP状态码的映射关系,确保客户端能够正确理解错误性质:
// AppError类中的映射
static toRestAPIError(err: unknown): {
status: 400 | 401 | 404 | 500;
body: { message: string };
} {
const error = AppError.parseError(err);
const status = match(error.code)
.with(AppErrorCode.INVALID_BODY, AppErrorCode.INVALID_REQUEST, () => 400 as const)
.with(AppErrorCode.UNAUTHORIZED, () => 401 as const)
.with(AppErrorCode.NOT_FOUND, () => 404 as const)
.otherwise(() => 500 as const);
return {
status,
body: { message: status !== 500 ? error.message : 'Something went wrong' }
};
}
常见错误码与HTTP状态码对应关系如下表:
| 错误码 | HTTP状态码 | 说明 |
|---|---|---|
| INVALID_REQUEST | 400 | 请求参数无效 |
| UNAUTHORIZED | 401 | 未授权访问 |
| NOT_FOUND | 404 | 资源不存在 |
| ALREADY_EXISTS | 409 | 资源已存在 |
| TOO_MANY_REQUESTS | 429 | 请求频率限制 |
| UNKNOWN_ERROR | 500 | 服务器内部错误 |
三、全局错误捕获机制
documenso在不同层级实现了全局错误捕获,确保未被手动处理的异常能够被统一捕获并转换为标准响应。
3.1 API层错误处理
在Hono API框架中,通过设置全局错误处理器来捕获所有API请求中的异常:
// packages/api/hono.ts
import { fetchRequestHandler } from '@ts-rest/serverless/fetch';
import { Hono } from 'hono';
export const tsRestHonoApp = new Hono<HonoEnv>();
tsRestHonoApp.mount('/', async (request) => {
return fetchRequestHandler({
request,
contract: ApiContractV1,
router: ApiContractV1Implementation,
options: {
errorHandler: (err) => {
if (err instanceof TsRestHttpError && err.statusCode === 500) {
console.error(err); // 记录服务器错误
}
},
},
});
});
虽然当前实现仅记录500错误,但可以扩展为使用AppError统一处理所有错误:
errorHandler: (err) => {
const appError = AppError.parseError(err);
console.error(`[API Error] ${appError.code}: ${appError.message}`);
// 返回标准化错误响应
return new Response(
JSON.stringify(AppError.toJSON(appError)),
{
status: appError.statusCode || 500,
headers: { 'Content-Type': 'application/json' }
}
);
}
3.2 Remix应用错误边界
在Remix前端框架中,通过ErrorBoundary组件捕获渲染和数据加载过程中的错误:
// apps/remix/app/root.tsx
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
const errorCode = isRouteErrorResponse(error) ? error.status : 500;
if (errorCode !== 404) {
console.error('[RootErrorBoundary]', error);
}
return <GenericErrorLayout errorCode={errorCode} />;
}
这个错误边界组件会捕获整个应用的前端错误,并显示相应的错误页面。对于后端API返回的错误,会通过AppError的parseError方法进行解析,确保错误信息的一致性。
3.3 服务器中间件错误处理
在Remix服务器中间件中,可以实现全局错误捕获:
// 可以添加到 apps/remix/server/middleware.ts
export const errorMiddleware = async (c: Context, next: Next) => {
try {
await next();
} catch (err) {
const error = AppError.parseError(err);
const { status, body } = AppError.toRestAPIError(error);
return c.json(body, { status });
}
};
// 在app中使用
app.use(errorMiddleware);
这种方式可以捕获所有通过中间件链的请求产生的异常,并统一转换为标准响应格式。
四、各层级错误处理实现
4.1 业务逻辑层错误抛出
在业务逻辑中,开发人员可以主动抛出AppError或其子类错误:
// 用户注册业务逻辑示例
async function registerUser(data: RegisterData) {
const existingUser = await prisma.user.findUnique({
where: { email: data.email }
});
if (existingUser) {
throw new UserExistsError();
// 或者使用更通用的AppError
// throw new AppError(AppErrorCode.ALREADY_EXISTS, {
// message: `User with email ${data.email} already exists`,
// userMessage: '该邮箱已被注册,请使用其他邮箱'
// });
}
// 其他业务逻辑...
}
4.2 API路由层错误处理
在API路由处理函数中,可以直接抛出错误,由上层错误处理器捕获:
// API实现示例
export const ApiContractV1Implementation = implementApiContractV1({
auth: {
register: async ({ body }) => {
try {
const user = await registerUser(body);
return { status: 201 as const, body: user };
} catch (err) {
// 可以选择在这里处理错误,或交给上层处理
const error = AppError.parseError(err);
// 转换为TsRest错误响应
throw new TsRestHttpError({
status: error.statusCode || 500,
body: { message: error.message },
});
}
}
}
});
4.3 TRPC错误处理
虽然在现有代码中未明确找到TRPC的错误处理配置,但可以基于TRPC的标准做法实现:
// 推荐实现:packages/trpc/server/index.ts
import { initTRPC } from '@trpc/server';
const t = initTRPC.context<Context>().create({
errorFormatter({ shape, error }) {
const appError = AppError.parseError(error);
return {
...shape,
data: {
...shape.data,
appError: AppError.toJSON(appError),
},
};
},
});
// 使用
export const router = t.router;
export const publicProcedure = t.procedure;
这样配置后,所有TRPC过程中抛出的错误都会被转换为包含AppError信息的标准响应格式。
4.4 数据库层错误处理
在Prisma中间件中,可以捕获数据库操作错误并转换为AppError:
// packages/prisma/prisma-middleware.ts
export function addErrorHandlingMiddleware(prisma: PrismaClient) {
prisma.$use(async (params, next) => {
try {
return await next(params);
} catch (error) {
// 处理唯一约束错误
if (error.code === 'P2002') {
const target = Array.isArray(error.meta?.target) ? error.meta.target[0] : error.meta?.target;
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
message: `Unique constraint failed on the ${target} field`,
userMessage: '该数据已存在,请检查输入信息'
});
}
// 处理记录不存在错误
if (error.code === 'P2025') {
throw new AppError(AppErrorCode.NOT_FOUND, {
message: error.message,
userMessage: '请求的资源不存在'
});
}
// 其他数据库错误
throw new AppError(AppErrorCode.DATABASE_ERROR, {
message: `Database error: ${error.message}`,
statusCode: 500
});
}
});
return prisma;
}
通过这种方式,可以将原始的Prisma错误转换为业务相关的AppError,使错误信息更具可读性和可操作性。
五、错误响应格式与客户端处理
5.1 统一错误响应格式
无论是REST API还是TRPC,错误最终都应转换为统一的JSON格式:
{
"code": "ALREADY_EXISTS",
"message": "User with email user@example.com already exists",
"userMessage": "该邮箱已被注册,请使用其他邮箱",
"statusCode": 409
}
其中:
code: 错误码,使用AppErrorCode枚举值message: 开发人员友好的错误信息,用于调试userMessage: 用户友好的错误信息,可直接展示给用户statusCode: 对应的HTTP状态码
5.2 客户端错误处理
在前端代码中,可以统一处理API错误响应:
// API客户端错误处理示例
async function apiRequest<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
try {
const response = await fetch(endpoint, options);
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new AppError(
errorData.code || AppErrorCode.UNKNOWN_ERROR,
{
message: errorData.message || `API error: ${response.status}`,
userMessage: errorData.userMessage,
statusCode: response.status
}
);
}
return await response.json();
} catch (err) {
const error = AppError.parseError(err);
// 显示用户友好错误信息
showNotification(error.userMessage || '操作失败,请稍后重试');
throw error; // 继续抛出,允许调用方进一步处理
}
}
六、最佳实践与进阶技巧
6.1 错误日志记录
在生产环境中,应详细记录错误信息以便排查问题,但需注意保护敏感数据:
// 错误日志记录改进
errorHandler: (err) => {
const error = AppError.parseError(err);
// 记录错误详情
logger.error({
message: `[${error.code}] ${error.message}`,
stack: error.stack,
requestId: c.get('requestId'), // 从上下文中获取请求ID
userId: c.get('context')?.user?.id, // 当前用户ID
path: c.req.path,
method: c.req.method
});
// 对客户端隐藏内部错误详情
const clientError = {
code: error.code === AppErrorCode.UNKNOWN_ERROR ? 'UNKNOWN_ERROR' : error.code,
message: process.env.NODE_ENV === 'production' && error.statusCode === 500
? '服务器内部错误'
: error.message,
userMessage: error.userMessage
};
return c.json(clientError, { status: error.statusCode || 500 });
}
6.2 错误监控与告警
对于严重错误,应配置实时监控与告警机制:
// 严重错误告警示例
if ([500, 401].includes(status) && error.code !== AppErrorCode.UNKNOWN_ERROR) {
// 发送告警到监控系统
await monitoringService.alert({
type: 'error',
severity: status === 500 ? 'critical' : 'warning',
code: error.code,
message: error.message,
requestId: c.get('requestId'),
timestamp: new Date().toISOString()
});
}
6.3 错误恢复策略
对于某些非致命错误,可以实现自动恢复机制:
// 带重试逻辑的数据库操作
async function withRetry<T>(fn: () => Promise<T>, retries = 3): Promise<T> {
try {
return await fn();
} catch (err) {
const error = AppError.parseError(err);
// 仅对特定错误类型进行重试
if (retries > 0 &&
[AppErrorCode.RETRY_EXCEPTION, 'P2023', 'P2030'].includes(error.code)) {
await sleep(100 * (4 - retries)); // 指数退避策略
return withRetry(fn, retries - 1);
}
throw error;
}
}
6.4 错误文档与类型定义
为提高开发效率,应为错误码提供完善的文档和TypeScript类型定义:
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考



