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 中间件场景下,推荐采用三层传播模型:

三层错误传播模型:
  1. 服务层(Service Layer): 捕获原始 S3 SDK 错误,进行分类与包装,抛出 NestJS 标准异常。
  2. 中间件层(Middleware Layer): 捕获服务层抛出的异常,注入日志链路 ID,并决定是否触发重试。
  3. 全局异常过滤器(Global Exception Filter): 最终捕获所有未处理的异常,统一格式化响应体,记录审计日志。

下图展示了错误在 NestJS 各层之间的传播路径:

NestJS 三层错误传播模型 服务层 (Service Layer) 捕获 S3 SDK 原始错误 → 分类 → 抛出 NestJS 标准异常 例如:NoSuchKey → NotFoundException 中间件层 (Middleware Layer) 捕获异常 → 注入 traceId → 判断是否重试 例如:服务端错误 → 触发重试拦截器 全局异常过滤器 (Global Exception Filter) 统一格式化响应 → 记录审计日志 → 返回给客户端 例如:{ statusCode: 404, message: "文件不存在", traceId: "xxx" }
常见陷阱:不要在中间件中直接捕获所有异常并返回响应,这会导致全局异常过滤器失效,破坏统一的错误响应格式。中间件应只处理与自身职责相关的错误(如重试、日志注入),其余错误应继续向上传播。

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 秒
  }
}

下图展示了指数退避重试策略的流程:

指数退避重试策略流程 发起 S3 请求 请求成功? 返回结果 判断错误类型 直接抛出异常 计算退避延迟 重新发起请求

4.4 本章小结

错误处理的核心原则是构建可靠 S3 中间件的基石。通过本章的学习,你应该掌握:

  • 错误分类:将错误明确划分为客户端错误(4xx)、服务端错误(5xx)和网络错误,并采取不同的处理策略。
  • 错误传播模型:采用服务层 → 中间件层 → 全局过滤器的三层传播模型,确保错误被正确分类、日志被完整记录、响应被统一格式化。
  • 幂等性与重试策略:对幂等操作使用指数退避 + 随机抖动的重试策略;对非幂等操作,在重试前必须进行前置条件检查或使用唯一标识符。