GraphQL 错误处理与可观测性:从 errors[] 到链路追踪

GraphQL 错误处理实战:errors[] 响应模型、业务错误 vs 系统错误、extensions.code 错误码规范、ApolloError 与自定义错误、输入校验聚合、null propagation 与 partial data、日志与链路追踪、客户端错误处理模式。

错误处理是 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,原因有二:

  1. 传输层(HTTP)已经成功;
  2. 业务层(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 语义4xx5xx

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_FOUNDNOT_FOUND_USER
权限错误FORBIDDEN_ / UNAUTHORIZEDFORBIDDEN_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,
  });
}

关键点:

  1. 记录错误码聚合(errorCodes)而非完整堆栈,便于聚合统计;
  2. 变量脱敏:password、token、secret 一律打码;
  3. 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
    }
  }
}

服务端处理顺序:

  1. 鉴权:未登录 → UNAUTHORIZED;
  2. 校验:金额、订单号格式 → ValidationError(聚合 issues);
  3. 业务规则:订单已支付 → RULE_ORDER_ALREADY_PAID;余额不足 → RULE_INSUFFICIENT_BALANCE;
  4. 调用支付网关:超时/失败 → 捕获并抛 UPSTREAM_TIMEOUT(retryable: true),内部细节进日志;
  5. 成功:返回订单新状态,并 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 让部分数据仍可展示,而结构化日志与链路追踪让每一个错误都可定位、可度量、可告警——最终形成对客户端友好、对运维透明的错误闭环。


相关阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「GraphQL」更多文章

  1. GraphQL Mutation 设计实战:从语义命名到乐观更新
  2. GraphQL 持久化查询与生产安全:从 APQ 到白名单的完整方案
  3. 游标分页与中继连接:从 offset 到 cursor 的工程实践