API 请求处理中的失败安全:限流、开关、鉴权与可观测性的默认值设计
1. 问题与目标
我们的后端服务同时承载几类流量:
- 面向用户的自助接口:提交申请、查询状态、查看我的记录;
- 外部系统的回调;
- 运维接口;
- 一个单页应用(SPA)前端。
部分用户请求会触发耗时较长、成本较高的外部操作,例如创建 Merge Request 和运行流水线。
在这样的服务里,横切关注点(限流、功能开关、鉴权、幂等、指标)本身也会出问题:配置缺失、配置写错、依赖服务异常、埋点代码抛出异常。我们的目标是:
- 为每个横切关注点明确定义它在”依赖缺失、配置错误、依赖失败”时的行为;
- 配置错误不会开放本应关闭的功能,也不会拦截本应放行的流量;
- 指标采集永远不改变业务结果;
- 错误响应对客户端有用,同时不泄露内部细节。
2. 约束与考量
- 服务多实例部署,没有专门用于限流的共享缓存。
- 配置来自配置中心并支持热更新。 这意味着配置可能缺失、为空或拼写错误,而且会在运行中发生变化。
- 调用方身份来自统一登录 SDK,字段形态不统一。 有时是邮箱形式的账号,有时是工号,需要统一解析成同一个身份。
- 不修改既有接口的返回结构。 部分历史接口直接返回
Map,横切逻辑不能依赖统一的返回类型。 - 前端发布后存在旧页面。 已打开的旧页面会请求新版本中已被删除的带 hash 静态资源。
- 团队约定可观测性代码只读不写。 埋点只能读取业务已经解析出的值,不能反过来影响业务逻辑。
3. 方案
3.1 拦截器的职责划分与顺序
拦截器按以下顺序注册,Spring 按注册顺序执行:
- 限流:仅作用于标注了
@RateLimit的处理方法。 - 特定路径的鉴权。
- API 延迟与状态码指标。
- 业务活动指标:只作用于一个明确列出的路径白名单。
限流排在第一位有两个原因。其一,被拒绝的请求不应消耗任何后续处理。其二,业务活动计数衡量的是”用户完成了多少次业务动作”,被 429 拒绝的请求不应计入其中;它们由单独的”被限流次数”指标统计。
业务活动指标使用路径白名单而不是通配符,这样与该业务无关的流量根本不会进入这个拦截器。
限流和鉴权都放在拦截器里实现,在参数绑定之前执行,有两个好处:
- 格式错误的请求体无法先触发 400、绕开或抢在鉴权之前返回;
- 拦截器自己写出响应,不需要修改各个接口的返回类型。
3.2 注解驱动、在参数绑定前执行的限流
@PostMapping("/apply")
@RateLimit(bucket = "apply", limitKey = "APPLY_RATE_LIMIT", defaultLimit = 10,
message = "提交过于频繁,请稍后再试")
public ResponseEntity<?> apply(@RequestBody Map<String, String> body) { ... }
public boolean preHandle(HttpServletRequest req, HttpServletResponse resp, Object handler) {
RateLimit rl = annotationOf(handler);
if (rl == null) return true;
int limit = config.getInt(rl.limitKey(), rl.defaultLimit()); // 每次请求读取,热更新即时生效
if (limit <= 0) return true; // 清空或非正数:停止限流,而不是全部拦截
String subject = resolveSubject(req);
if (limiter.tryAcquire(rl.bucket() + ":" + subject, limit, windowMillis())) return true;
metrics.recordRateLimited(subject, rl.bucket());
writeJson(resp, 429, ApiResponse.error("RATE_LIMITED", rl.message()));
return false;
}
限流的主体优先使用登录身份。 这样同一个人换浏览器或开多个标签页,消耗的是同一份额度。只有在读不到身份时,才退回到客户端地址,并加上 ip: 前缀与身份区分。之所以退回到地址而不是归入一个共享桶,是因为共享桶会被任意一个调用方耗尽,进而影响所有无法识别身份的调用方。
限流器是滑动窗口日志。 每个桶保存一个时间戳队列,在 ConcurrentHashMap.compute 中完成”清理过期时间戳、判断、追加”,整个过程对单个桶是原子的。桶清空后返回 null,该键即被移除:
buckets.compute(key, (k, q) -> {
Deque<Long> d = (q == null) ? new ArrayDeque<>() : q;
while (!d.isEmpty() && now - d.peekFirst() >= windowMillis) d.pollFirst();
if (d.size() < limit) { d.addLast(now); allowed[0] = true; }
return d.isEmpty() ? null : d;
});
取舍需要写进注释。 限流先于幂等检查执行,所以超出额度的重复提交得到的是 429,而不是”重复提交”。我们接受这一点,并在接口注释中写明,避免后来的维护者误以为这是缺陷。
3.3 配置的默认值:按误配置的后果决定方向
热更新配置随时可能缺失或写错。我们为每个配置项明确规定缺省时的行为,规则是:看误配置的主要后果是什么,朝后果较轻的方向缺省。
| 配置项 | 误配置的主要后果 | 缺省行为 |
|---|---|---|
| 限流阈值 | 误伤正常流量 | 不限流(阈值 ≤ 0 视为关闭限流) |
| 受限资源的开放模式 | 资源被意外开放 | 未知值视为”关闭” |
| 受限资源的开放时间窗口 | 资源被意外开放 | 任一字段缺失或格式错误即视为不在窗口内 |
| 鉴权所需的规则码 | 越权访问 | 缺失即拒绝 |
| 新流程开关 | 启用未经验证的路径 | 走旧流程 |
| 重试次数、退避时间 | 负值导致跳过调用或负数休眠 | 下限截断为 0 |
开放模式的解析如下:
AccessMode resolve(String raw) {
if (raw == null || raw.isBlank()) return TEST_WINDOW; // 未配置:受限开放(默认)
return switch (raw.trim().toUpperCase(Locale.ROOT)) {
case "OPEN" -> OPEN;
case "TEST_WINDOW" -> TEST_WINDOW;
default -> CLOSED; // 拼写错误等任何未知值:关闭
};
}
开放时间窗口按固定时区解析,而不是使用 JVM 默认时区,保证同一份配置在每台主机上对应同一段时间。
3.4 鉴权:失败即拒绝,拒绝时不泄露信息
- 权限服务调用的任何异常都按”无权限”处理,规则码缺失同样按”无权限”处理。
- 一次往返校验多个规则码,用于”管理员也可以访问”这类组合判断。
- 区分 401 与 403。 读不到登录身份,或登录 SDK 抛出异常,返回 401;身份有效但无权限,或权限服务异常,返回 403。
- 403 的响应消息固定为 “Access denied”,被拒绝的调用方无法得知需要哪一个权限。
- 运维控制台的操作者身份由服务端写入。 服务端先复制客户端提交的参数,再用登录身份覆盖保留的
__authenticatedOperator参数。顺序不能颠倒,否则客户端可以通过提交同名参数伪造操作者。审计日志只记录经过脱敏和长度截断的参数。
3.5 错误响应:对客户端提供可操作的信息,细节留在日志
我们在接口层区分两类错误:
// 客户端错误:返回一条可操作的提示,具体细节只写日志
ResponseEntity<?> clientFacingError(String code, String userMessage, String logDetail) {
log.warn("rejected code={} detail={}", code, logDetail);
return ApiResponse.error(code, userMessage);
}
// 服务端错误:日志记录原因和堆栈,客户端只收到通用提示
ResponseEntity<?> internalError(String code, String logDetail, Throwable cause) {
log.error("internal code={} detail={}", code, logDetail, cause);
return ApiResponse.error(code, GENERIC_ERROR_MESSAGE);
}
状态码按”客户端接下来应该做什么”来设计,而不是按”服务端内部发生了什么”:
DUPLICATE_SUBMIT与ACCEPTED分开。 两者的 HTTP 状态都是 202,但前端收到DUPLICATE_SUBMIT时会保持表单锁定,而不是把它当作一次新的受理。- “等待人工处理”按能否自行恢复拆成两种提示。 如果请求停在一个可以对原 MR 重试的步骤上,提示”可以继续等待”;如果停在确定性的外部失败上(例如流水线失败),提示”需要联系维护团队,继续等待不会生效”。哪些步骤属于后者,由一个常量集合集中定义。
- 只读的预览接口失败时返回空数据,不阻塞用户继续提交。
3.6 幂等与长操作
幂等。 写接口以客户端提供的 operationId 作为幂等键,依赖数据库唯一约束完成”查询、插入、冲突后重读”(详见数据库篇 3.4 节)。并发的重复请求中,只有一个会继续执行,其余的返回胜出方的结果。
长操作。 长操作在完成同步校验后返回 HTTP 202,后续在有界线程池中执行。线程池拒绝任务时,失败状态会写入数据库(详见 MR 生命周期篇 3.2 节)。客户端始终能通过轮询看到一个明确的状态。
3.7 可观测性不干扰业务
我们为指标代码制定了几条规则,并在实现中逐条落实:
- 指标拦截器的
preHandle永远返回true,从不写响应。 - 指标代码只读取已经解析好的值。 例如读取请求参数时使用
request.getParameter,而不是自己读请求体,避免消耗输入流、影响后续的参数绑定。 - 指标使用独立的身份解析逻辑。 身份不可信时(例如只能拿到一个可能是工号的裸 ID),直接跳过本次计数,不做猜测,解析结果也不回流到业务逻辑。
- 计数器写入包裹在
try/catch中,异常只记日志。标签值经过规范化,并截断到 128 个字符;维度为空或非法时跳过发送。 - 按语义选择计数时机。 “收到请求”在请求到达时计数;”成功访问”在
afterCompletion中计数,并且只统计 2xx 响应。 - 业务结果指标在事务提交后才发送,并且只在状态真正发生翻转时发送,避免回滚或并发导致多计(详见 MR 生命周期篇 3.7 节)。
- 指标代码放在失败处理逻辑之外。 例如批次移交完成后才记录耗时,这样指标代码即使抛出异常,也不会触发批次的失败结算。
public void incrementUserCounter(String name, Map<String, String> tags) {
try {
Map<String, String> safe = normalizeAndTruncate(tags, 128);
if (safe == null) { log.warn("skip metric {}: invalid tags", name); return; }
registry.counter(name, safe).inc();
} catch (RuntimeException e) {
log.error("metric {} failed; business request continues", name, e); // 指标失败不影响业务
}
}
3.8 前端发布期间的静态资源回退
SPA 发布之后,已打开的旧页面会请求新版本中已被删除的带 hash 资源。如果不做处理,这类请求会落到全局异常处理器,返回 500。我们从服务端和客户端两侧处理:
服务端:
index.html设置Cache-Control: no-store,保证每次都拿到最新的资源清单;- 带 hash 的资源设置
max-age=365d, public, immutable; - 资源不存在时按请求类型分别响应:页面导航请求(
Accept: text/html且路径不含扩展名)返回一个回退页面,静态资源或 API 请求返回 404 JSON。两种响应都设置no-store。
客户端:
- 在捕获阶段监听脚本和样式的加载错误;核心资源加载失败时,带上缓存破坏参数重新加载页面;
- 用
sessionStorage记录上次自动重载的时间。30 秒内不再自动重载,改为显示一个”刷新页面”按钮,避免陷入重载循环。
我们还为这些响应头和状态码编写了冒烟脚本,在预发布环境验证。
4. 成果
- 每个横切关注点的缺省行为都有明确定义,并写在代码注释和配置说明中。配置被清空或写错时,限流退化为不限流,开放类开关退化为关闭,鉴权退化为拒绝。
- 限流和鉴权在参数绑定之前完成,不需要修改任何既有接口的返回结构。
- 被限流的请求不会污染业务指标,它们由独立的指标统计。
- 指标代码中的异常不影响业务请求。 指标不读取请求体,不写响应,也不参与业务分支判断。
- 错误响应按”下一步行动”区分。 用户能区分”重复提交”“可以继续等待”和”需要联系团队”,内部细节只出现在服务端日志里。
- 前端发布期间,旧页面能自行恢复,不再出现由缺失资源引起的 500。
5. 已知限制与后续计划
- 限流按实例生效。 实际的全局额度约等于”单实例阈值 × 实例数”。另外,退回到地址限流时取的是
X-Forwarded-For的第一跳,而这个值客户端可以伪造。后续计划改为取可信代理追加的那一跳,或在网关层统一限流。 - 身份解析逻辑有三份副本,规则存在细微差异。 其中一份对邮箱形式的账号不做截断,可能导致同一个人在指标中出现两个标签值。后续计划收敛为一个共享的解析组件。
- 异常与 HTTP 状态码的映射不一致。 部分业务异常返回 500;有一处鉴权拒绝经由通用服务异常返回 500 而不是 403;兜底处理器把异常消息原样返回给客户端。后续计划统一错误模型,兜底处理器只返回通用提示。
- 部分外部 SDK 调用没有设置超时,也没有熔断。 外部依赖变慢时会长时间占用请求线程。后续计划为所有外部调用设置显式超时,并为关键依赖增加隔离。
- 幂等只记录
operationId,不重放原始响应。 如果客户端没有提供operationId,服务端生成的 ID 含有时间戳,重试请求无法去重。 - API 指标拦截器自身没有
try/catch。 它能否做到失败不影响业务,取决于追踪和指标库本身不抛异常。另外,部分用户接口不在 API 指标的覆盖范围内。