tRPC 错误处理 #

错误处理概览 #

tRPC 提供了完整的错误处理机制,包括服务端错误抛出、错误类型定义、客户端错误捕获等。

text
┌─────────────────────────────────────────────────────────────┐
│                    tRPC 错误流程                             │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   服务端                                                    │
│   ┌─────────────────────────────────────────────┐          │
│   │  Procedure / Middleware                      │          │
│   │  throw new TRPCError({ ... })               │          │
│   └─────────────────────────────────────────────┘          │
│                        │                                    │
│                        ▼                                    │
│   ┌─────────────────────────────────────────────┐          │
│   │  tRPC 格式化错误                             │          │
│   │  {                                          │          │
│   │    error: {                                 │          │
│   │      message: string,                       │          │
│   │      code: TRPC_ERROR_CODE,                 │          │
│   │      data: { ... }                          │          │
│   │    }                                        │          │
│   │  }                                          │          │
│   └─────────────────────────────────────────────┘          │
│                        │                                    │
│                        ▼                                    │
│   客户端                                                    │
│   ┌─────────────────────────────────────────────┐          │
│   │  捕获错误                                    │          │
│   │  try/catch 或 onError 回调                  │          │
│   └─────────────────────────────────────────────┘          │
│                                                             │
└─────────────────────────────────────────────────────────────┘

TRPCError #

错误类型 #

tRPC 定义了以下错误代码:

typescript
type TRPCErrorCodes = 
  | 'PARSE_ERROR'
  | 'BAD_REQUEST'
  | 'UNAUTHORIZED'
  | 'FORBIDDEN'
  | 'NOT_FOUND'
  | 'METHOD_NOT_SUPPORTED'
  | 'TIMEOUT'
  | 'CONFLICT'
  | 'PRECONDITION_FAILED'
  | 'PAYLOAD_TOO_LARGE'
  | 'UNSUPPORTED_MEDIA_TYPE'
  | 'UNPROCESSABLE_CONTENT'
  | 'TOO_MANY_REQUESTS'
  | 'CLIENT_CLOSED_REQUEST'
  | 'INTERNAL_SERVER_ERROR';
text
┌─────────────────────────────────────────────────────────────┐
│                    错误代码对照表                            │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│  错误代码              HTTP 状态码    说明                   │
│  ─────────────────────────────────────────────────────────  │
│  PARSE_ERROR           400           解析错误               │
│  BAD_REQUEST           400           请求错误               │
│  UNAUTHORIZED          401           未授权                 │
│  FORBIDDEN             403           禁止访问               │
│  NOT_FOUND             404           未找到                 │
│  METHOD_NOT_SUPPORTED  405           方法不支持             │
│  TIMEOUT               408           请求超时               │
│  CONFLICT              409           冲突                   │
│  PRECONDITION_FAILED   412           前置条件失败           │
│  PAYLOAD_TOO_LARGE     413           负载过大               │
│  TOO_MANY_REQUESTS     429           请求过多               │
│  CLIENT_CLOSED_REQUEST 499           客户端关闭请求         │
│  INTERNAL_SERVER_ERROR 500           服务器内部错误         │
│                                                             │
└─────────────────────────────────────────────────────────────┘

创建错误 #

typescript
import { TRPCError } from '@trpc/server';

const router = t.router({
  user: t.router({
    getById: t.procedure
      .input(z.object({ id: z.string() }))
      .query(async ({ input }) => {
        const user = await db.user.findUnique({
          where: { id: input.id },
        });

        if (!user) {
          throw new TRPCError({
            code: 'NOT_FOUND',
            message: `User with id ${input.id} not found`,
          });
        }

        return user;
      }),

    create: t.procedure
      .input(createUserSchema)
      .mutation(async ({ input }) => {
        const existing = await db.user.findUnique({
          where: { email: input.email },
        });

        if (existing) {
          throw new TRPCError({
            code: 'CONFLICT',
            message: 'Email already exists',
          });
        }

        return db.user.create({ data: input });
      }),
  }),
});

错误属性 #

typescript
interface TRPCErrorOptions {
  code: TRPCErrorCodes;
  message?: string;
  cause?: Error;
}

class TRPCError extends Error {
  public readonly cause?: Error;
  public readonly code: TRPCErrorCodes;

  constructor(options: TRPCErrorOptions) {
    super(options.message ?? options.code);
    this.cause = options.cause;
    this.code = options.code;
  }
}

服务端错误处理 #

基本错误抛出 #

typescript
const router = t.router({
  user: t.router({
    delete: t.procedure
      .input(z.object({ id: z.string() }))
      .mutation(async ({ input, ctx }) => {
        if (!ctx.user) {
          throw new TRPCError({
            code: 'UNAUTHORIZED',
            message: 'You must be logged in to delete a user',
          });
        }

        const user = await db.user.findUnique({
          where: { id: input.id },
        });

        if (!user) {
          throw new TRPCError({
            code: 'NOT_FOUND',
            message: 'User not found',
          });
        }

        if (ctx.user.id !== input.id && ctx.user.role !== 'admin') {
          throw new TRPCError({
            code: 'FORBIDDEN',
            message: 'You can only delete your own account',
          });
        }

        await db.user.delete({ where: { id: input.id } });
        
        return { success: true };
      }),
  }),
});

错误与原始异常 #

typescript
const router = t.router({
  user: t.router({
    create: t.procedure
      .input(createUserSchema)
      .mutation(async ({ input }) => {
        try {
          return await db.user.create({ data: input });
        } catch (error) {
          if (error instanceof Prisma.PrismaClientKnownRequestError) {
            if (error.code === 'P2002') {
              throw new TRPCError({
                code: 'CONFLICT',
                message: 'Email already exists',
                cause: error,
              });
            }
          }
          
          throw new TRPCError({
            code: 'INTERNAL_SERVER_ERROR',
            message: 'Failed to create user',
            cause: error,
          });
        }
      }),
  }),
});

验证错误 #

typescript
import { z } from 'zod';

const router = t.router({
  user: t.router({
    update: t.procedure
      .input(z.object({
        id: z.string(),
        email: z.string().email(),
      }))
      .mutation(async ({ input }) => {
        const result = await updateUser(input);
        
        if (!result.success) {
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: result.error,
          });
        }
        
        return result.data;
      }),
  }),
});

自定义错误数据 #

typescript
interface CustomErrorData {
  code: string;
  details: Record<string, any>;
  stack?: string;
}

const router = t.router({
  user: t.router({
    create: t.procedure
      .input(createUserSchema)
      .mutation(async ({ input }) => {
        const validationErrors = validateUser(input);
        
        if (validationErrors.length > 0) {
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: 'Validation failed',
            cause: new Error(JSON.stringify({
              code: 'VALIDATION_ERROR',
              details: validationErrors,
            })),
          });
        }
        
        return db.user.create({ data: input });
      }),
  }),
});

错误中间件 #

typescript
const errorMiddleware = t.middleware(async ({ next }) => {
  try {
    return await next();
  } catch (error) {
    if (error instanceof TRPCError) {
      throw error;
    }

    if (error instanceof z.ZodError) {
      throw new TRPCError({
        code: 'BAD_REQUEST',
        message: 'Validation failed',
        cause: error,
      });
    }

    if (error instanceof Prisma.PrismaClientKnownRequestError) {
      switch (error.code) {
        case 'P2002':
          throw new TRPCError({
            code: 'CONFLICT',
            message: 'Record already exists',
            cause: error,
          });
        case 'P2025':
          throw new TRPCError({
            code: 'NOT_FOUND',
            message: 'Record not found',
            cause: error,
          });
      }
    }

    console.error('Unexpected error:', error);
    
    throw new TRPCError({
      code: 'INTERNAL_SERVER_ERROR',
      message: 'An unexpected error occurred',
      cause: error,
    });
  }
});

export const publicProcedure = t.procedure.use(errorMiddleware);

客户端错误处理 #

try/catch #

typescript
import { TRPCClientError } from '@trpc/client';
import type { AppRouter } from '../server/routers';

async function createUser(data: CreateUserInput) {
  try {
    const user = await client.user.create.mutate(data);
    return { success: true, user };
  } catch (error) {
    if (error instanceof TRPCClientError) {
      console.error('tRPC Error:', error.message);
      console.error('Error code:', error.data?.code);
      
      return { 
        success: false, 
        error: error.message,
        code: error.data?.code,
      };
    }
    
    throw error;
  }
}

React 错误处理 #

tsx
import { trpc } from './trpc';

function UserList() {
  const { data, error, isError, isLoading } = trpc.user.list.useQuery();

  if (isLoading) {
    return <div>Loading...</div>;
  }

  if (isError) {
    return (
      <div className="error">
        <h2>Error</h2>
        <p>{error.message}</p>
        <p>Code: {error.data?.code}</p>
      </div>
    );
  }

  return (
    <ul>
      {data?.map((user) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

Mutation 错误处理 #

tsx
function CreateUser() {
  const utils = trpc.useUtils();
  
  const createUser = trpc.user.create.useMutation({
    onSuccess: (data) => {
      utils.user.list.invalidate();
      toast.success(`User ${data.name} created!`);
    },
    onError: (error) => {
      switch (error.data?.code) {
        case 'CONFLICT':
          toast.error('Email already exists');
          break;
        case 'BAD_REQUEST':
          toast.error('Invalid input data');
          break;
        case 'UNAUTHORIZED':
          toast.error('Please log in first');
          break;
        default:
          toast.error(`Error: ${error.message}`);
      }
    },
  });

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      const formData = new FormData(e.currentTarget);
      createUser.mutate({
        name: formData.get('name') as string,
        email: formData.get('email') as string,
      });
    }}>
      <input name="name" placeholder="Name" />
      <input name="email" placeholder="Email" />
      <button type="submit" disabled={createUser.isPending}>
        Create
      </button>
      {createUser.isError && (
        <p className="error">{createUser.error.message}</p>
      )}
    </form>
  );
}

全局错误处理 #

tsx
import { QueryCache, QueryClient } from '@tanstack/react-query';
import { TRPCClientError } from '@trpc/client';

const queryClient = new QueryClient({
  queryCache: new QueryCache({
    onError: (error) => {
      if (error instanceof TRPCClientError) {
        if (error.data?.code === 'UNAUTHORIZED') {
          window.location.href = '/login';
        }
      }
    },
  }),
  mutationCache: new MutationCache({
    onError: (error) => {
      if (error instanceof TRPCClientError) {
        toast.error(error.message);
      }
    },
  }),
});

错误边界 #

tsx
import { ErrorBoundary } from 'react-error-boundary';

function ErrorFallback({ error, resetErrorBoundary }: {
  error: Error;
  resetErrorBoundary: () => void;
}) {
  return (
    <div className="error-boundary">
      <h2>Something went wrong</h2>
      <p>{error.message}</p>
      <button onClick={resetErrorBoundary}>Try again</button>
    </div>
  );
}

function App() {
  return (
    <ErrorBoundary FallbackComponent={ErrorFallback}>
      <UserList />
    </ErrorBoundary>
  );
}

错误格式化 #

自定义错误格式 #

typescript
import { initTRPC, TRPCError } from '@trpc/server';

const t = initTRPC.create({
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        customError: {
          code: error.code,
          message: error.message,
          timestamp: new Date().toISOString(),
          path: shape.path,
        },
      },
    };
  },
});

国际化错误消息 #

typescript
const errorMessages: Record<string, Record<string, string>> = {
  en: {
    UNAUTHORIZED: 'Please log in to continue',
    FORBIDDEN: 'You do not have permission',
    NOT_FOUND: 'The requested resource was not found',
    CONFLICT: 'This resource already exists',
  },
  zh: {
    UNAUTHORIZED: '请先登录',
    FORBIDDEN: '没有权限',
    NOT_FOUND: '请求的资源不存在',
    CONFLICT: '资源已存在',
  },
};

const t = initTRPC.create({
  errorFormatter({ shape, error, ctx }) {
    const locale = ctx?.locale ?? 'en';
    
    return {
      ...shape,
      message: errorMessages[locale]?.[error.code] ?? error.message,
    };
  },
});

结构化错误 #

typescript
interface StructuredError {
  code: string;
  message: string;
  details?: Array<{
    field: string;
    message: string;
  }>;
}

const router = t.router({
  user: t.router({
    create: t.procedure
      .input(createUserSchema)
      .mutation(async ({ input }) => {
        const errors: Array<{ field: string; message: string }> = [];

        if (await isEmailTaken(input.email)) {
          errors.push({
            field: 'email',
            message: 'Email is already taken',
          });
        }

        if (input.name.length < 2) {
          errors.push({
            field: 'name',
            message: 'Name must be at least 2 characters',
          });
        }

        if (errors.length > 0) {
          throw new TRPCError({
            code: 'BAD_REQUEST',
            message: 'Validation failed',
            cause: new Error(JSON.stringify({
              code: 'VALIDATION_ERROR',
              details: errors,
            })),
          });
        }

        return db.user.create({ data: input });
      }),
  }),
});

错误恢复 #

重试机制 #

tsx
function UserList() {
  const { data, error, isError, refetch } = trpc.user.list.useQuery(
    undefined,
    {
      retry: 3,
      retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
    }
  );

  if (isError) {
    return (
      <div>
        <p>Error: {error.message}</p>
        <button onClick={() => refetch()}>Retry</button>
      </div>
    );
  }

  return <ul>{/* ... */}</ul>;
}

自动重连 #

typescript
import { retryLink } from '@trpc/client';

const client = createTRPCProxyClient<AppRouter>({
  links: [
    retryLink({
      maxAttempts: 3,
      retry: (opts) => {
        if (opts.error.data?.code === 'UNAUTHORIZED') {
          return false;
        }
        return opts.attempts < 3;
      },
    }),
    httpBatchLink({
      url: 'http://localhost:4000/trpc',
    }),
  ],
});

优雅降级 #

tsx
function UserList() {
  const { data, error, isError } = trpc.user.list.useQuery();

  if (isError) {
    return (
      <div>
        <p>Unable to load users. Showing cached data.</p>
        <CachedUserList />
      </div>
    );
  }

  return <ul>{/* ... */}</ul>;
}

调试技巧 #

详细日志 #

typescript
const debugMiddleware = t.middleware(async ({ path, type, input, next }) => {
  console.log('Request:', { path, type, input });

  try {
    const result = await next();
    console.log('Response:', { path, result });
    return result;
  } catch (error) {
    console.error('Error:', { path, error });
    throw error;
  }
});

错误追踪 #

typescript
import * as Sentry from '@sentry/node';

const sentryMiddleware = t.middleware(async ({ path, type, next }) => {
  try {
    return await next();
  } catch (error) {
    Sentry.captureException(error, {
      tags: {
        trpc_path: path,
        trpc_type: type,
      },
    });
    throw error;
  }
});

开发环境错误 #

typescript
const t = initTRPC.create({
  errorFormatter({ shape, error }) {
    if (process.env.NODE_ENV === 'development') {
      return {
        ...shape,
        data: {
          ...shape.data,
          stack: error.cause?.stack,
        },
      };
    }
    return shape;
  },
});

最佳实践 #

1. 使用正确的错误代码 #

typescript
throw new TRPCError({
  code: 'NOT_FOUND',
  message: 'User not found',
});

throw new TRPCError({
  code: 'UNAUTHORIZED',
  message: 'Please log in',
});

throw new TRPCError({
  code: 'FORBIDDEN',
  message: 'Admin access required',
});

2. 提供有意义的错误消息 #

typescript
throw new TRPCError({
  code: 'BAD_REQUEST',
  message: `User with email ${input.email} already exists`,
});

3. 保留原始错误 #

typescript
try {
  await someOperation();
} catch (error) {
  throw new TRPCError({
    code: 'INTERNAL_SERVER_ERROR',
    message: 'Operation failed',
    cause: error,
  });
}

4. 统一错误处理 #

typescript
const errorHandlerMiddleware = t.middleware(async ({ next }) => {
  try {
    return await next();
  } catch (error) {
    if (error instanceof TRPCError) {
      throw error;
    }
    throw new TRPCError({
      code: 'INTERNAL_SERVER_ERROR',
      message: 'An unexpected error occurred',
      cause: error,
    });
  }
});

5. 客户端错误分类 #

typescript
function handleTRPCError(error: TRPCClientError<AppRouter>) {
  switch (error.data?.code) {
    case 'UNAUTHORIZED':
      redirectToLogin();
      break;
    case 'FORBIDDEN':
      showForbiddenMessage();
      break;
    case 'NOT_FOUND':
      showNotFoundPage();
      break;
    default:
      showGenericError(error.message);
  }
}

下一步 #

现在你已经掌握了 tRPC 的错误处理机制,接下来学习 高级特性,了解 tRPC 的更多高级功能!

最后更新:2026-03-29