错误处理是 API 工程中最容易被低估的部分。REST 时代我们用 HTTP 状态码表达错误,但 GraphQL 的响应永远是 200——错误被结构化地放在 errors[] 数组里。这种设计更优雅,也更容易被做坏:要么所有错误都返回一段"Error: something went wrong",要么把内部堆栈直接抛给客户端。本文讲解如何建立一套从错误模型、错误码、到日志与链路追踪的完整错误处理体系,让 GraphQL 服务的错误既可诊断、又对客户端友好。
一、错误响应模型(errors[])
1.1 GraphQL 规范的响应结构
GraphQL 响应永远是三段式:
{
"data": { "user": null },
"errors": [
{
"message": "User not found",
"locations": [{ "line": 2, "column": 3 }],
"path": ["user"]
}
],
"extensions": {
"requestId": "req_01HZ..."
}
}
| 字段 | 语义 |
|---|---|
message | 人类可读的错误信息(对客户端或最终用户) |
locations | 错误对应查询中的行列位置 |
path | 错误发生在响应数据的哪个字段路径 |
extensions | 可扩展字段,放错误码、异常详情、requestId 等机器可读信息 |
1.2 200 状态码的真相
GraphQL 端点无论成功与否几乎都返回 HTTP 200,原因有二:
- 传输层(HTTP)已经成功;
- 业务层(GraphQL)的错误应通过响应体表达。
但这带来一个现实问题:CDN、网关、监控系统无法通过状态码区分成功与失败。生产实践通常是:
| 场景 | HTTP 状态码 | 说明 |
|---|---|---|
| 正常响应(含业务错误) | 200 | 标准 GraphQL |
| 语法/校验错误 | 200(或 400) | Apollo Server 4 对语法错误返回 400 |
| 认证失败 | 401 | 在网关/中间件层拦截 |
| 授权失败 | 403 | 同上 |
| 系统不可用 | 502/503 | 网关兜底,通常不走 GraphQL |
错误响应的不可变约定:业务错误一律放 errors[];message 面向客户端、不包含内部细节;机器可读信息放 errors[].extensions;顶层 extensions 放跨错误的共享元数据(requestId、traceId)。
二、业务错误 vs 系统错误
2.1 两类错误的本质区别
| 维度 | 业务错误(Client Error) | 系统错误(Server Error) |
|---|---|---|
| 起因 | 客户端输入/权限/状态不合法 | 服务端依赖故障、代码缺陷 |
| 可预期性 | 可预期、可枚举 | 不可预期 |
| 是否可重试 | 通常不可重试 | 通常可重试 |
| 对客户端展示 | 需要展示给用户 | 只显示"稍后重试" |
| 是否记录告警 | 一般不告警 | 必须告警 |
| HTTP 语义 | 4xx | 5xx |
2.2 在 resolver 中显式区分
// 业务错误:抛出带有明确 code 的 GraphQLError
throw new GraphQLError('Insufficient balance', {
extensions: { code: 'INSUFFICIENT_BALANCE', field: 'amount' },
});
// 系统错误:包装下游异常,保留内部信息但不上抛给客户端
catch (err) {
logger.error('payment service failed', { err, requestId });
throw new GraphQLError('Payment service temporarily unavailable', {
extensions: { code: 'UPSTREAM_UNAVAILABLE', retryable: true },
});
}
2.3 业务错误的分层分类
业务错误本身也应分层,否则客户端难以定位问题:
| 业务错误类型 | code 前缀 | 示例 |
|---|---|---|
| 输入校验错误 | VALIDATION_ | VALIDATION_EMAIL_INVALID |
| 资源未找到 | NOT_FOUND | NOT_FOUND_USER |
| 权限错误 | FORBIDDEN_ / UNAUTHORIZED | FORBIDDEN_INSUFFICIENT_ROLE |
| 状态冲突 | CONFLICT_ | CONFLICT_ALREADY_EXISTS |
| 业务规则违例 | RULE_ | RULE_INSUFFICIENT_BALANCE |
| 外部依赖失败 | UPSTREAM_ | UPSTREAM_TIMEOUT |
原则:业务错误是领域语言的一部分,系统错误是工程关注的一部分。让错误码直接映射到领域概念,运维与开发才能快速对齐。
三、错误码规范(extensions.code)
3.2 错误码设计规范
一套健康的错误码体系应满足:
| 规范 | 说明 | 示例 |
|---|---|---|
| 全大写 + 下划线 | 与枚举一致 | NOT_FOUND |
| 可读、自解释 | 不看文档也能猜出含义 | EMAIL_ALREADY_REGISTERED |
| 全局唯一 | 在整站范围内不重复 | 禁止不同模块共用 ERROR |
| 携带定位信息 | 可附加字段名 | extensions.field = "email" |
| 有版本演进策略 | 废弃码需有迁移期 | OLD_CODE → NEW_CODE |
3.4 避免常见的错误码反模式
| 反模式 | 危害 | 正确做法 |
|---|---|---|
全用 INTERNAL_ERROR | 无法区分问题类型 | 按业务/系统分类细分 |
| 用 message 做判断 | message 变更即破坏 | 用 code,message 仅展示 |
| 把堆栈放入 message | 泄漏内部细节 | 堆栈只进日志,message 面向用户 |
| 同一个错误码多种含义 | 客户端误判 | 一码一义,必要时拆码 |
四、ApolloError 与自定义错误类
4.1 Apollo Server 的错误类型体系
Apollo Server 提供 ApolloError 基类与一组内置错误类(AuthenticationError、ForbiddenError、UserInputError 等,来源于 @apollo/server/errors):
import { GraphQLError } from 'graphql';
// Apollo Server 4 中推荐直接用 GraphQLError + extensions
throw new GraphQLError('Insufficient balance', {
extensions: { code: 'RULE_INSUFFICIENT_BALANCE', http: { status: 422 } },
});
Apollo Server 4 起官方推荐直接用 GraphQLError 并携带 extensions,而非继承 ApolloError。http.status 可让网关依据 extensions 决定 HTTP 状态码。
4.2 自定义业务错误类
当错误需要携带结构化字段时(如校验失败的多个 field),自定义类更清晰:
// errors/ValidationError.ts
import { GraphQLError } from 'graphql';
interface FieldIssue {
field: string;
code: string;
message: string;
}
export class ValidationError extends GraphQLError {
constructor(issues: FieldIssue[]) {
super('Validation failed', {
extensions: {
code: 'VALIDATION_FAILED',
issues,
},
});
}
}
抛出与消费:
throw new ValidationError([
{ field: 'email', code: 'EMAIL_INVALID', message: '邮箱格式不正确' },
{ field: 'password', code: 'PASSWORD_TOO_SHORT', message: '密码至少 8 位' },
]);
4.3 统一的格式化出口
在 Apollo Server 的 formatError 中做最后的脱敏与规范化,防止内部信息泄漏:
import { unwrapResolverError } from '@apollo/server/errors';
const server = new ApolloServer({
typeDefs,
resolvers,
formatError: (formattedError, error) => {
// 系统错误的内部 stack 只进日志
const unwrapped = unwrapResolverError(error);
if (unwrapped instanceof Error && (unwrapped as any).internal) {
logger.error('internal error', { err: unwrapped });
}
return {
message: formattedError.message,
path: formattedError.path,
extensions: {
code: formattedError.extensions?.code ?? 'INTERNAL_ERROR',
field: formattedError.extensions?.field,
issues: formattedError.extensions?.issues,
requestId: reqContext.requestId,
},
};
},
});
五、输入校验错误聚合
5.1 单一校验失败 vs 聚合校验
表单提交类的 mutation 最常见的问题是逐字段报错:第一个错误返回后,客户端修好再发现第二个错误,来回多次。工程上应聚合所有字段错误,一次返回。
import { z } from 'zod';
const createUserSchema = z.object({
email: z.string().email('邮箱格式不正确'),
password: z.string().min(8, '密码至少 8 位'),
nickname: z.string().min(1, '昵称不能为空').max(20, '昵称最长 20 字'),
});
export async function createUser(_root, args, ctx) {
const parsed = createUserSchema.safeParse(args.input);
if (!parsed.success) {
const issues = parsed.error.issues.map((issue) => ({
field: issue.path.join('.'),
code: `VALIDATION_${issue.code.toUpperCase()}`,
message: issue.message,
}));
throw new ValidationError(issues); // 一次返回全部字段问题
}
// ... 业务逻辑
}
5.2 聚合校验的响应形态
客户端拿到的是结构化 issues,可直接渲染到表单字段:errors[].extensions.issues 中逐条携带 field、code、message,前端表单按 field 定位渲染,无需解析字符串。
六、null propagation 与 partial data
6.1 错误导致的 null 传播
在 GraphQL 中,错误与 null 是强关联的:非空字段的 resolver 抛错时,该字段及其父链逐级变为 null。
type Order {
id: ID!
# 若 items 抛错,整个 Order 变 null
items: [OrderItem!]!
}
data.order 变成 null,同时 errors[] 里有对应错误。客户端必须理解这种"部分成功、部分失败"的语义。
6.2 可空字段的容错设计
为了最大化 partial data(部分数据仍可展示),应让可降级的字段保持 nullable:
type Order {
id: ID!
status: OrderStatus!
# 推荐强列表
items: [OrderItem!]!
# 次级字段允许失败降级
shipping: ShippingInfo
}
type ShippingInfo {
carrier: String!
trackingNo: String
estimatedDelivery: String
}
shipping 若解析失败返回 null,整个 order 仍可正常展示,前端只隐藏运输信息区块。
6.3 客户端如何消费 partial data
客户端应把"数据 + 错误"当作同一份状态来消费:
// Apollo Client:正确处理 partial data
const { data, error } = useQuery(GET_ORDER, { errorPolicy: 'all' });
// errorPolicy: 'all' 让 errors 不阻塞 data 的返回
if (data?.order) {
renderOrder(data.order); // 渲染可用的部分
if (error) {
renderInlineWarning(error); // 同时提示局部失败
}
} else {
renderErrorState(error); // 整体失败
}
七、日志与链路追踪(trace)
7.1 请求级日志模板
每个 GraphQL 请求都应产生一条结构化日志,包含可复现的全部上下文:
// middleware/request-logger.ts
export function logRequest(context) {
const { requestId, operationName, query, variables, errors, latency } = context;
logger.info('graphql.request', {
requestId,
operationName,
query: operationName === undefined ? query : undefined, // 变量化查询不记全量
variables: sanitize(variables), // 脱敏密码、token
errorCodes: errors?.map((e) => e.extensions?.code),
latencyMs,
userId: context.user?.id,
});
}
关键点:
- 记录错误码聚合(
errorCodes)而非完整堆栈,便于聚合统计; - 变量脱敏:
password、token、secret一律打码; - requestId 贯穿:让一条请求在日志系统里可完整串起来。
7.2 链路追踪:把 GraphQL 融入现有 trace
GraphQL 服务通常处于前端与下游微服务之间。要让 traceId 贯穿三层,需在请求入口注入 span,并在调用下游时透传:
import { trace, context, SpanStatusCode } from '@opentelemetry/api';
import { RequestHandler } from 'express';
export const traceMiddleware: RequestHandler = (req, res, next) => {
const tracer = trace.getTracer('graphql-server');
return tracer.startActiveSpan('graphql.request', (span) => {
span.setAttribute('http.method', req.method);
const requestId = req.headers['x-request-id'] as string ?? crypto.randomUUID();
span.setAttribute('graphql.requestId', requestId);
res.locals.requestId = requestId;
// 将 traceId 透传给下游
const propagationHeaders = {};
propagation.inject(context.active(), propagationHeaders);
res.locals.propagationHeaders = propagationHeaders;
res.on('finish', () => {
span.setStatus(res.statusCode >= 500 ? SpanStatusCode.ERROR : SpanStatusCode.OK);
span.end();
});
next();
});
};
八、客户端错误处理模式
8.1 统一错误处理器
前端应建立统一的 GraphQL 错误处理层,而不是每个组件手写分支:
// apollo/link/errorHandler.ts
import { onError } from '@apollo/client/link/error';
export const errorLink = onError(({ graphQLErrors, networkError, operation }) => {
if (graphQLErrors) {
for (const err of graphQLErrors) {
const code = err.extensions?.code;
switch (code) {
case 'UNAUTHORIZED':
redirectToLogin();
return;
case 'FORBIDDEN':
notify('没有权限执行此操作');
return;
case 'VALIDATION_FAILED':
// 交由表单组件读取 issues 渲染
return;
default:
notify(err.message);
}
}
}
if (networkError) {
notify('网络异常,请稍后重试');
}
});
8.2 错误码到 UI 的映射表
客户端维护一张"错误码 → 用户提示/行为"映射表,保证全站提示一致:
| 错误码 | 用户提示 | 行为 |
|---|---|---|
UNAUTHORIZED | 请重新登录 | 跳转登录页 |
FORBIDDEN | 无操作权限 | 禁用按钮 |
NOT_FOUND | 内容不存在或已删除 | 显示空态 |
VALIDATION_FAILED | 表单错误 | 字段级渲染 |
CONFLICT_ALREADY_EXISTS | 已存在重复资源 | 提示修改 |
RULE_INSUFFICIENT_BALANCE | 余额不足 | 引导充值 |
UPSTREAM_* | 服务繁忙 | 重试按钮 + 指数退避 |
8.3 重试与幂等
网络错误与部分系统错误应支持重试。重试必须配合幂等键(见 mutation 设计专题),否则可能重复下单:
// 重试策略:指数退避 + 抖动
const retryDelay = (attempt: number) => Math.min(1000 * 2 ** attempt, 10_000) + Math.random() * 300;
原则:4xx 类业务错误不要重试,5xx 类系统错误可以重试但必须幂等。把重试决策交给统一错误处理器,而不是散落在业务代码里。
九、错误处理综合实践
9.1 一个 mutation 的完整错误链路
以"支付订单"为例,串起整套错误处理体系:
mutation PayOrder($input: PayOrderInput!) {
payOrder(input: $input) {
order {
id
status
}
}
}
服务端处理顺序:
- 鉴权:未登录 →
UNAUTHORIZED; - 校验:金额、订单号格式 →
ValidationError(聚合 issues); - 业务规则:订单已支付 →
RULE_ORDER_ALREADY_PAID;余额不足 →RULE_INSUFFICIENT_BALANCE; - 调用支付网关:超时/失败 → 捕获并抛
UPSTREAM_TIMEOUT(retryable: true),内部细节进日志; - 成功:返回订单新状态,并 publish 变更事件(见 mutation 设计专题)。
9.3 十条错误处理铁律
- 错误码放
extensions.code,message 只做人话; - 业务错误与系统错误必须可区分(code/retryable);
- 内部堆栈、SQL、token 永远不进客户端响应;
- 输入校验要聚合,一次返回所有字段问题;
- 可降级字段保持 nullable,最大化 partial data;
- 客户端显式配置 errorPolicy,不静默吞错误;
- 每条请求记录 requestId + 错误码聚合日志;
- traceId 贯穿 GraphQL 与下游,端到端可追踪;
- 系统错误率告警 P0,业务错误率告警 P2;
- 4xx 不重试,5xx 幂等重试。
FAQ
Q1: 为什么 GraphQL 不直接返回 HTTP 4xx/5xx?
因为 GraphQL 响应是"部分成功"模型,同一请求中可能同时存在成功字段与失败字段,单一 HTTP 状态码无法表达。传输层面仍是 200;认证/授权等请求级失败可在网关层用 401/403 拦截。Apollo Server 4 对语法错误返回 400 是例外。
Q2: extensions.code 与 message 的分工是什么?
code 是稳定、机器可读、可用于分支判断的契约;message 是面向用户的展示文本,可随语言、版本变化。客户端判断逻辑一律基于 code,绝不要解析 message。
Q3: 自定义 GraphQLError 的 extensions 里可以放任意字段吗?
可以,extensions 是开放对象。但建议收敛为约定结构:code、field、issues、retryable、requestId 等固定键。随意塞字段会降低可维护性,也建议在 formatError 里做白名单过滤,防止内部字段外泄。
Q4: 如何在错误处理中避免泄露敏感信息?
三层防护:抛出时只带业务信息;formatError 统一脱敏并剥离堆栈;日志层对 variables 打码。另外用测试用例覆盖"故意抛内部错误时响应体不含堆栈"这一断言。
Q5: 客户端拿到 data.order == null 时如何区分"订单不存在"与"内部字段失败"?
看 errors[]:订单不存在通常表现为 code: 'NOT_FOUND' 且无其他错误;内部字段失败则伴随具体错误码(如 UPSTREAM_*)。设计上建议"根字段未找到用 null 表达、内部失败用 errors 表达",两者通过 errorPolicy 配合消费。
一句话总结
GraphQL 错误处理的精髓是把"错误"当作一等公民的契约来设计:
extensions.code让错误可机器消费,null propagation让部分数据仍可展示,而结构化日志与链路追踪让每一个错误都可定位、可度量、可告警——最终形成对客户端友好、对运维透明的错误闭环。
相关阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。