4、错误处理核心原则:错误分类、传播模型与重试策略
在构建与 S3 兼容对象存储交互的 NestJS 中间件时,错误处理绝非简单的 try-catch。一个健壮的错误处理体系需要从三个维度进行设计:错误分类(区分错误来源与性质)、错误传播模型(决定错误如何向上传递与转换)、幂等性与重试策略(确保系统在失败后能安全恢复)。本章将逐一剖析这些核心原则,并给出在 NestJS 中间件中的落地实践。
4.1 错误分类:客户端错误、服务端错误与网络错误
错误分类是错误处理的第一步。将错误归入正确的类别,决定了后续的日志记录级别、告警策略以及是否触发重试。在 S3 接口场景下,错误主要分为三大类:
| 错误类别 | 典型 HTTP 状态码 | S3 错误示例 | 处理策略 |
|---|---|---|---|
| 客户端错误 (4xx) | 400, 403, 404, 409 | InvalidAccessKeyId, NoSuchBucket, BucketAlreadyExists | 记录警告日志,直接返回错误给调用方,不重试 |
| 服务端错误 (5xx) | 500, 503 | InternalError, ServiceUnavailable | 记录错误日志,触发告警,可重试(需配合退避策略) |
| 网络错误 (0) | 无响应 / 连接超时 | ECONNRESET, ETIMEDOUT, DNS 解析失败 | 记录严重错误日志,触发告警,可重试(需考虑幂等性) |
实践要点:在 NestJS 中间件中,建议使用自定义异常过滤器(ExceptionFilter)来统一捕获并分类错误。对于客户端错误,直接抛出 NestJS 内置的
BadRequestException、NotFoundException 等;对于服务端错误,抛出 InternalServerErrorException;对于网络错误,则将其包装为 GatewayTimeoutException 或自定义的 S3NetworkException。
以下是一个在 NestJS 服务层进行错误分类的代码示例:
// s3-error-classifier.service.ts
import { Injectable, Logger } from '@nestjs/common';
import {
S3Client,
GetObjectCommand,
NoSuchKey,
NoSuchBucket,
S3ServiceException,
} from '@aws-sdk/client-s3';
@Injectable()
export class S3ErrorClassifierService {
private readonly logger = new Logger(S3ErrorClassifierService.name);
async getObject(bucket: string, key: string) {
try {
const command = new GetObjectCommand({ Bucket: bucket, Key: key });
return await this.s3Client.send(command);
} catch (error) {
// 1. 客户端错误:不重试,直接返回
if (error instanceof NoSuchKey || error instanceof NoSuchBucket) {
this.logger.warn(`客户端错误:资源不存在 - ${error.name}`);
throw error; // 让过滤器处理为 404
}
// 2. 服务端错误:可重试
if (error instanceof S3ServiceException && error.$retryable === true) {
this.logger.error(`服务端错误:S3 服务不可用 - ${error.message}`);
throw error; // 让重试拦截器处理
}
// 3. 网络错误:可重试
if (error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT') {
this.logger.error(`网络错误:连接中断 - ${error.code}`);
throw new S3NetworkException(error.message);
}
// 4. 未知错误:兜底
this.logger.error(`未知错误:${error.message}`, error.stack);
throw new InternalServerErrorException('S3 操作异常');
}
}
}
4.2 错误传播模型:从中间件到全局过滤器的链路
错误传播模型定义了错误在 NestJS 请求处理管道中的流动路径。一个清晰的传播模型可以避免错误被多次捕获、日志重复记录或状态码被错误覆盖。在 S3 中间件场景下,推荐采用三层传播模型:
三层错误传播模型:
- 服务层(Service Layer): 捕获原始 S3 SDK 错误,进行分类与包装,抛出 NestJS 标准异常。
- 中间件层(Middleware Layer): 捕获服务层抛出的异常,注入日志链路 ID,并决定是否触发重试。
- 全局异常过滤器(Global Exception Filter): 最终捕获所有未处理的异常,统一格式化响应体,记录审计日志。
下图展示了错误在 NestJS 各层之间的传播路径:
常见陷阱:不要在中间件中直接捕获所有异常并返回响应,这会导致全局异常过滤器失效,破坏统一的错误响应格式。中间件应只处理与自身职责相关的错误(如重试、日志注入),其余错误应继续向上传播。
4.3 幂等性与重试策略
在分布式系统中,网络抖动或服务临时不可靠是常态。重试是提高系统可用性的重要手段,但必须与幂等性结合,否则重试可能导致数据不一致或资源重复创建。
4.3.1 幂等性判断:哪些 S3 操作是幂等的?
幂等性是指无论执行一次还是多次,系统状态都保持一致。在 S3 操作中,不同操作的幂等性不同:
| S3 操作 | 是否幂等 | 说明 |
|---|---|---|
GetObject |
是 | 多次读取同一对象,返回相同数据(假设对象未被修改) |
PutObject (覆盖) |
是 | 多次上传相同内容到同一 Key,最终对象内容一致 |
DeleteObject |
是 | 多次删除同一对象,最终状态都是“不存在” |
CreateBucket |
否 | 重复创建同名 Bucket 会返回 BucketAlreadyExists 错误 |
UploadPart |
否 | 重复上传同一分片可能导致分片版本混乱 |
重试策略建议:对于幂等操作(如 GetObject、PutObject),可以放心使用指数退避重试;对于非幂等操作(如 CreateBucket),重试前必须检查前置条件(如先调用 HeadBucket 确认 Bucket 不存在),或使用唯一标识符(如 UUID)作为 Key 的一部分来避免冲突。
4.3.2 指数退避与抖动(Jitter)
简单的固定间隔重试会导致“惊群效应”(Thundering Herd),即大量客户端在同一时刻发起重试,压垮后端服务。因此,生产环境必须使用指数退避 + 随机抖动策略。
以下是一个在 NestJS 中间件中实现重试策略的代码示例:
// retry.interceptor.ts
import { Injectable, Logger, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common';
import { Observable, throwError, timer } from 'rxjs';
import { retryWhen, mergeMap, delayWhen } from 'rxjs/operators';
@Injectable()
export class RetryInterceptor implements NestInterceptor {
private readonly logger = new Logger(RetryInterceptor.name);
private readonly maxRetries = 3;
private readonly baseDelay = 1000; // 1 秒
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next.handle().pipe(
retryWhen(errors =>
errors.pipe(
mergeMap((error, index) => {
// 只对服务端错误和网络错误进行重试
if (this.isRetryable(error) && index < this.maxRetries) {
const delay = this.calculateDelay(index);
this.logger.warn(
`操作失败,第 ${index + 1} 次重试,延迟 ${delay}ms - ${error.message}`
);
return timer(delay);
}
// 不可重试或达到最大次数,直接抛出
return throwError(() => error);
})
)
)
);
}
private isRetryable(error: any): boolean {
// 服务端错误 (5xx) 或网络错误
return error.status >= 500 || error.code === 'ECONNRESET' || error.code === 'ETIMEDOUT';
}
private calculateDelay(attempt: number): number {
// 指数退避: baseDelay * 2^attempt + 随机抖动 (0 ~ 1000ms)
const exponentialDelay = this.baseDelay * Math.pow(2, attempt);
const jitter = Math.random() * 1000;
return Math.min(exponentialDelay + jitter, 10000); // 最大延迟 10 秒
}
}
下图展示了指数退避重试策略的流程:
4.4 本章小结
错误处理的核心原则是构建可靠 S3 中间件的基石。通过本章的学习,你应该掌握:
- 错误分类:将错误明确划分为客户端错误(4xx)、服务端错误(5xx)和网络错误,并采取不同的处理策略。
- 错误传播模型:采用服务层 → 中间件层 → 全局过滤器的三层传播模型,确保错误被正确分类、日志被完整记录、响应被统一格式化。
- 幂等性与重试策略:对幂等操作使用指数退避 + 随机抖动的重试策略;对非幂等操作,在重试前必须进行前置条件检查或使用唯一标识符。