联合测试目标
这一节将 promisifyServiceMethod 的错误回调和 GrpcRetryInterceptor 的重试机制联合测试,验证完整的错误处理链路。
测试步骤
1. 启动服务并设置断点
# 启动所有服务
pnpm dev:gateway # Gateway 服务
pnpm dev:user # User 微服务 (40001)
pnpm dev:user1 # User 微服务 (40002)
pnpm dev:health # 健康检查服务
bash
在以下关键位置设置断点:
auth.service.ts第 35 行(error callback)grpc-retry.interceptor.ts第 26 行(拦截器入口)
2. 模拟服务故障
# 手动停止 40001 端口的微服务
# 在运行 user 微服务的终端按 Ctrl+C
bash
3. 发起请求触发错误
# 使用 Bruno 或 curl 发起请求
curl http://localhost:3040/api/v1/auth/login
bash
关键 Bug 修复记录
Bug 1:catch 无法捕获 Promise 异常
现象:promisifyServiceMethod 中的 catch 块从未被执行。
原因:firstValueFrom() 返回 Promise,但没有 await,try/catch 只能捕获同步异常。
// ❌ 问题代码
try {
return firstValueFrom(originalMethod.apply(service, args));
} catch (error) {
// 永远不会执行
}
// ✅ 修复后
try {
return await firstValueFrom(originalMethod.apply(service, args));
} catch (error) {
errorCallbackFunction(error);
throw error;
}
typescript
Bug 2:拦截器中 async 导致 Observable 流中断
现象:拦截器的 retry 逻辑从未触发,错误直接被抛出。
原因:在 RxJS 的 catchError 中使用了 async 关键字,导致返回值被自动包装为 Promise 而非 Observable,中断了整个响应式流。
// ❌ 问题代码:async 使返回值变成 Promise
catchError(async (error) => {
// ...
return throwError(() => new Error('Service update timeout'));
})
// ✅ 修复后:去掉 async,保持返回 Observable
catchError((error) => {
// ...
return throwError(() => new Error('Service update timeout'));
})
typescript
RxJS 中 async/await 的陷阱
这是调试过程中最关键的教训:
| 场景 | 是否需要 await | 原因 |
|---|---|---|
firstValueFrom() 在 try/catch 中 | 需要 | 要等待 Promise 完成/拒绝 |
catchError 操作符回调 | 不需要 | 返回必须是 Observable |
switchMap 回调 | 谨慎使用 | 可能打断响应式链 |
retry 的 delay 函数 | 不需要 | 必须返回 Observable(timer) |
核心原则:在 RxJS 的操作符回调中,如果需要返回 Observable,绝对不能使用 async。
完整的测试流程验证
正常流程
- 请求到达 AuthService
grpcClient()返回 GrpcClientInterface- BehaviorSubject 发出 ClientGrpc 实例
userService.findOne()正常调用- 返回结果
故障转移流程
- 停止 40001 端口的微服务
- 请求到达 AuthService
userService.findOne()调用失败firstValueFrom的 Promise 被 rejectawait将 reject 转为 throw- catch 块捕获 →
errorCallbackFunction(error)被调用 - 错误继续抛出 → 到达拦截器的
catchError checkIfConnectionIssue判断为连接问题- 进入
handleConnectionIssue:- 延迟 1 秒
- 执行
next.handle()(重试) - retry 最多 3 次,指数退避
- 重试成功 → 返回结果
- 重试耗尽 → 抛出 "Service update timeout"
重试次数验证
通过调试断点可以确认重试的执行次数:
| 重试 | 延迟 | 累计 | 行为 |
|---|---|---|---|
| 初始请求 | 0 | 0 | 失败,进入拦截器 |
| 初始延迟 | 1s | 1s | 等待 Consul 状态更新 |
| 第 1 次重试 | 2s | 3s | Math.pow(2, 1) = 2 |
| 第 2 次重试 | 4s | 7s | Math.pow(2, 2) = 4 |
| 第 3 次重试 | 8s | 15s | Math.pow(2, 3) = 8 |
| 最终失败 | - | 15s | 抛出 timeout 错误 |
GrpcClientInterface 的设计前瞻
在 errorCallbackFunction 中捕获到错误后,可以通过 GrpcClientInterface 的完整上下文信息来执行实例切换:
// GrpcClientInterface 上的属性提供了切换所需的所有信息
{
client: BehaviorSubject, // 可以通过 next() 更新客户端
consulName: string, // 知道当前使用哪个 Consul
consulClient: Consul, // 可以查询其他健康实例
serviceName: string, // 知道需要切换哪个服务
consulService: ConsulService, // 可以调用 updateService 方法
}
typescript
这种"一层套一层"的设计虽然复杂,但为故障转移提供了完整的数据支撑。
↑