首页 / 文章

在 Angular 中处理请求失败:状态、拦截器与重试机制

学习如何在 Angular 中对 HTTP 错误进行分类、处理加载状态、在拦截器中集中处理逻辑、安全地重试,并显示用户可采取行动的消息。

发布者:REACTAPP.TOP阅读约 9 分钟2160 词

来源: **Handling API Failures Gracefully in Angular: Practical Error Handling Patterns Every Frontend Developer Should Know**

大多数 Angular 组件都是为正常情况设计的:返回 200 OK 状态,数据存在于属性中,模板得以渲染。但在实际生产环境中,连接会中断、会话会过期、后端可能会崩溃,用户还可能双击提交按钮。以下这些模式能让 Angular 应用程序检测到这些故障,向用户说明问题,在安全的情况下进行恢复,并为开发人员提供足够的信息以找出根本原因。

忽略错误回调的代价

典型的组件请求仅会订阅响应、赋值数据,除此之外不会做任何其他操作。

this.http.get('/api/accounts').subscribe({
  next: (response) => {
    this.accounts = response;
  }
});

如果服务器返回 500 错误状态,用户几乎看不出任何变化。页面依然为空或持续显示加载动画,唯一的痕迹只是用户永远不会查看的控制台日志。

最起码的改进是添加一个 error 处理器,用于记录故障信息并为模板提供易于理解的提示文字。

this.http.get('/api/accounts').subscribe({
  next: (response) => {
    this.accounts = response;
  },
  error: (error) => {
    console.error('Failed to load accounts', error);
    this.errorMessage = 'Unable to load accounts. Please try again.';
  }
});

这种方式只适用于单个页面。在数十个页面中到处复制处理逻辑会导致消息不一致、遗漏一些边缘情况以及代码重复,这就是为什么可重用模式如此重要的原因。

并非所有错误都意味着相同的问题

状态码通常能告诉你谁可以解决该问题,进而决定应如何应对。

400 错误请求:输入有误

前端发送了无效数据。

400 Bad Request

用户通常可以自行解决,例如修正表单字段。

401 未授权:会话缺失或已过期

缺少认证信息,最常见的原因是访问令牌已过期。

401 Unauthorized

常见的处理方式是重定向到登录页面,或先更新令牌。

403 禁止访问:已通过认证但无权限

服务器知道该用户,但拒绝其执行相应操作。

403 Forbidden

这是基于角色访问控制的常规情况。让用户重新登录并无帮助;他们需要明确得知自己缺乏相应权限。

404 未找到:该资源不存在

该实体已被删除或URL已过期。

404 Not Found

500 服务器内部错误:后端出现故障

服务器上发生了某些故障。

500 Internal Server Error

用户无法自行解决此问题;应提供清晰的提示以及稍后重试的途径。

网络错误:请求从未到达

有时请求根本无法送达服务器。

Internet connection lost
Server unavailable
DNS failure
Request timeout

Angular会将这些情况作为状态码为0的HttpErrorResponse来处理,以此将其与真正的服务器响应区分开来。这一分类决定了后续的操作——是否重试、是否重定向,以及应向用户传达什么信息。

将请求建模为一组状态

只跟踪成功状态是个常见错误。应将请求视为一个小型状态机:它从加载开始,最终以成功或失败告终。

Loading
   ↓
Success

or

Loading
   ↓
Error

在组件中,这意味着需要为加载状态和错误信息设置明确的标志,并在每个分支中更新这些标志。

loading = false;
errorMessage = '';
loadAccounts() {
  this.loading = true;
  this.errorMessage = '';
  this.http.get('/api/accounts').subscribe({
    next: (data) => {
      this.accounts = data;
      this.loading = false;
    },
    error: () => {
      this.errorMessage =
        'Unable to load accounts. Please try again.';
      this.loading = false;
    }
  });
}

关键在于要在错误回调中重置loading状态;否则每次失败都会导致加载指示器持续显示。在开始时清除errorMessage也能避免旧错误信息与新数据一同显示。

让finalize()负责清理加载标志

在两个地方重置该标志容易出错。RxJS的finalize()功能为清理操作提供了统一的位置。

import { finalize } from 'rxjs';
loadAccounts() {
  this.loading = true;
  this.http.get('/api/accounts')
    .pipe(
      finalize(() => {
        this.loading = false;
      })
    )
    .subscribe({
      next: (data) => {
        this.accounts = data;
      },
      error: () => {
        this.errorMessage =
          'Unable to load accounts.';
      }
    });
}

finalize() 回调会在可观察对象完成处理或出现错误时触发,同时在订阅被提前取消时也会执行。正因如此,它非常适合用于隐藏加载指示器,这样一来那些重复的 this.loading = false 代码行也就不再需要了。

在 HTTP 拦截器中集中处理共享行为

会话过期或一般性故障应在所有页面上以相同方式处理,因此按组件分别实现既重复又容易出错。HTTP 拦截器能够捕获所有的 HttpClient 请求,因此是实施全应用范围策略的理想位置。此示例采用了函数式的 HttpInterceptorFn 风格,并根据状态进行分支处理。

export const errorInterceptor: HttpInterceptorFn =
  (req, next) => {
    return next(req).pipe(
      catchError((error: HttpErrorResponse) => {
        if (error.status === 401) {
          console.log('Session expired');
        }
        if (error.status === 403) {
          console.log('Access denied');
        }
        if (error.status >= 500) {
          console.log('Server error');
        }
        return throwError(() => error);
      })
    );
  };

这些日志记录调用实际上只是用于重定向、提示信息或监控事件的占位符。更重要的是,拦截器以 throwError(() => error) 结尾,因此错误仍然会传递给调用该功能的组件。拦截器负责处理通用逻辑,而组件则处理其自身能处理的业务逻辑,这种分离方式有助于保持代码的可维护性。函数式拦截器是通过 provideHttpClient(withInterceptors([...])) 注册的。

将技术错误转化为易于理解的用户提示

后端并不总是返回友好的文本:

{
  "message": "NullPointerException at AccountService.java:142"
}

这样的处理方式对服务维护人员有帮助,但展示给客户时会造成混淆,并泄露实现细节。应改为显示面向用户的内容。

We couldn't load your accounts right now.
Please try again.

会话过期这一情况也需要专门的表述:

Your session has expired.
Please sign in again.

权限问题同样如此:

You don't have permission to access this resource.

一条好的错误信息应能回答三个问题:

  1. 出了什么问题?
  2. 用户需要采取行动吗?
  3. 他们接下来可以做什么?

将状态码与错误信息统一映射在同一个地方,有助于保持表述的一致性。

重试短暂故障,但仅限安全的故障

有些故障是暂时的,比如网络波动。对于这类情况,RxJS提供了retry()功能。

this.http.get('/api/accounts')
  .pipe(
    retry(2)
  )
  .subscribe({
    next: (data) => {
      this.accounts = data;
    },
    error: () => {
      this.errorMessage =
        'Unable to load accounts.';
    }
  });

retry(2)会在错误传递到error回调之前最多再重试两次。由于HttpClient的观察者是冷启动的,每次重试都会发送新的请求。

这同时也存在风险。如果支付请求在服务器端已成功处理,但响应丢失,除非后端支持幂等性,否则重试操作可能会导致向客户收取两次费用。对于以下场景需谨慎使用自动重试:

  • 支付
  • 订单
  • 账户创建
  • 数据更新
  • 删除操作
  • 只读的 GET 请求更易于理解。还需注意,单纯的 retry() 会在每次出错时都尝试重试,包括那些永远无法成功的 400 或 401 错误;通常情况下,只需对网络错误和 5xx 响应进行重试即可。

    带延迟的重试机制

    立即重试往往会导致请求再次发送到同样超载的服务器。retry() 的配置选项允许添加延迟时间。

    import { retry } from 'rxjs';
    this.http.get('/api/accounts')
      .pipe(
        retry({
          count: 2,
          delay: 1000
        })
      )
      .subscribe();
    

    请求会有两次重试机会,间隔一秒。delay 也支持函数参数,这样就可以实现指数级退避策略(每次等待时间比上一次更长),并且可以跳过那些注定无法成功的错误的重试。这种退避机制能防止大量失败的客户端同步冲击正在恢复中的服务器。

    将过期的会话视为全局性问题

    一个典型的场景是:用户早上打开应用程序,几小时后再次返回。此时令牌已过期,导致后续请求失败。

    GET /api/accounts
    401 Unauthorized
    

    应在拦截器中检测这一问题:

    if (error.status === 401) {
      // Clear authentication state
      // Redirect to login
    }
    

    在统一位置处理该问题可避免各个组件重复实现会话过期检测功能。借助刷新令牌,拦截器可在重定向前尝试更新令牌;当有多个请求同时失败时,请参阅为何并发的401错误会导致用户登出以及单次刷新解决方案以了解相关处理方法。

    为用户提供重试按钮

    通常最有效的恢复手段就是一个用于重复发送请求的普通按钮。

    <div *ngIf="errorMessage">
      <p>{{ errorMessage }}</p>
      <button (click)="loadAccounts()">
        Try Again
      </button>
    </div>
    

    想象一下,有用户的连接中断了几秒钟。当他们重新上线后,无需重新加载应用程序并丢失状态,只需按下:

    Try Again
    

    因为 loadAccounts() 会先重置状态,所以它既适用于初始加载,也适用于每次重试。Angular 17+ 的模板可以使用 @if 代替 *ngIf。

    在数据仍有效时使用旧数据

    如果仪表板刷新失败,显示上次加载的数值并附上诚实的提示,通常比空页面更好。

    Showing your last available data.
    We couldn't refresh the dashboard.
    

    缓存使得这成为可能,而提示信息则至关重要。避免在数据时效性至关重要的场景中使用这种模式:余额、股票价格、库存和支付详情应显示错误信息,而非过时的数值。

    记录足够的上下文以便调试故障

    向用户隐藏细节并不意味着要丢弃这些信息。一份有用的日志记录应包含:

    Endpoint
    HTTP status
    Timestamp
    Request identifier
    User/session context where appropriate
    Error category
    

    最简版的拦截器实现:

    console.error({
      endpoint: req.url,
      status: error.status,
      message: error.message
    });
    

    在生产环境中没人会查看控制台,因此应将数据发送到错误监控服务,让请求标识符将其与后端日志关联起来。绝不要记录令牌或不必要的个人数据。

    需要把握的平衡点是:用户能收到有用的提示,开发者则能获得足够的细节来查明问题原因。

    不要用空结果掩盖故障

    一种诱人的捷径是直接忽略错误并返回空值:

    catchError(() => {
      return of([]);
    })
    

    这会悄悄改变含义:空数组意味着服务器返回了零条记录。用户看到没有交易记录时,可能会误以为自己确实没有任何交易,这比出现错误更糟糕。备用方案可以是正确的,但必须是刻意为之;否则应直接抛出错误:

    catchError((error) => {
      return throwError(() => error);
    })
    

    在全局处理与功能级处理之间划分职责

    那些在所有地方都具有相同意义的问题适合由全局处理来处理:

    401 → Authentication handling
    403 → Authorization handling
    500 → Generic server error
    

    这类问题应该放在拦截器中。现在考虑以下情况:

    POST /api/transfer-money
    

    转账失败时需要具体的说明以及明确表明资金是否已转移,而通用的提示信息无法做到这一点。相关功能必须能够接收到错误信息,因此拦截器会重新抛出错误。稳健的设计应该是对通用问题采用全局处理,而在业务上下文重要的地方则采用功能级处理。

    故障路径审查清单

    错误处理旨在设计系统出错后的应对方案。在审查某个功能时,可询问以下问题:

    • 用户能否理解发生了什么?
    • 他们无需重新加载页面就能恢复吗?
    • 是否有重试的途径?
    • 加载指示器会始终停止吗?
    • 技术细节是否被隐藏在界面之外?
    • 故障信息是否会记录在开发者可以查看的地方?
    • 会话进行中令牌过期时会发生什么?
    • 网络连接中断时会发生什么?
    • 后端返回的数据格式异常时会发生什么?

    核心要点

    故障是正常现象,因此应追求优雅的错误处理方式。在 Angular 中,这体现在:

    • 每个请求都有明确的加载、成功和错误状态
  • finalize(),确保无论请求如何结束都能执行清理操作
  • catchError()用于处理错误,而非隐藏错误
  • 针对会话过期、权限问题以及通用服务器错误的拦截器,这些拦截器会重新抛出异常以便相关功能能够作出响应
  • 重试仅限于安全且可重复执行的请求,并带有延迟或退避机制
  • 向用户说明发生了什么以及接下来该做什么的提示信息
  • 手动重试按钮,以及在数据新鲜度允许的情况下标注清晰的备用数据
  • 在正式环境中将结构化日志发送至监控服务
  • 一个可靠的应用程序体现在出错时的处理方式上。