编程

API 设计:错误处理

517 2023-05-24 10:27:00

错误是创建API时最容易忽略的事。用户会时不时遇到问题,错误是他们最先看到的东西。在上面花时间是值得的,这样可以使API有更好的用户体验。

› 指导原则

好的错误消息应该遵循下面几点:

  1. 说明哪里出错了。
  2. 解释你能做什么。
  3. 对于可恢复的错误,易于隔离和处理。

› 案例研究

我们将重新使用前文的HTTP客户端案例,其 API 表层如下所示:

package http

import (
  "io"
)

type Response struct {
  StatusCode int
  Headers map[string]string
  Body io.ReadCloser
}

type options struct {}
type Option = func(*options)

var (
  FollowRedirects = func(o *options) {}
  Header = func(key, value string) func(*options) {}
)

func Get(url string, options ...Option) (Response, error) {}

下面是一个实际调用的例子:

package main

import (
  "fmt"
  "http"
  "io"
  "os"
)

func main() {
  res, err := http.Get("https://example.com", http.FollowRedirects, http.Header("Accept-Encoding", "gzip"))
  if err != nil {
    fmt.Fprintln(os.Stderr, err.Error())
    os.Exit(1)
  }

  if res.StatusCode != 200 {
    fmt.Fprintf(os.Stderr, "non-200 status code: %v\n", res.StatusCode)
    os.Exit(1)
  }

  _, err := io.Copy(os.Stdout, res.Body)
  if err != nil {
    fmt.Fprintln(os.Stderr, err.Error())
    os.Exit(1)
  }

  if err := res.Body.Close(); err != nil {
    fmt.Fprintln(os.Stderr, err.Error())
    os.Exit(1)
    }
}

需要澄清的是,该原则适用于所有语言,不仅限于 Go。

› 有用的错误信息

当返回错误时,首先需要区分的是调用者是否可以对此采取任何措施。

以网络错误为例。以下是网络连接程序正常操作的全部内容:

  1. 目标进程已崩溃,正在启动备份。
  2. 您和目的地之间的节点出现故障,没有转发任何数据包。
  3. 目标进程过载,正在限制客户端的速率以帮助恢复。

以下是发生上述情况时我希望看到的内容:

HTTP GET request to https://example.com failed with error: connection refused, ECONNREFUSED. Run man 2 connect for more information. You can pass http.NumRetries(int) as an option, but keep in mind that retrying too much can get you rate limited or blacklisted from some sites.

这让我免于在线挖掘、查阅文档,以及愤怒的网站管理员或自动限速器的一记耳光。它非常符合我们指导原则中的第1点和第2点。

不过,这不是您想要向最终用户显示的内容。我们需要为API用户提供处理错误的工具(比如为他们提供重试选项),或者向最终用户显示一条漂亮的错误消息。

› 不同类型的错误

不同语言对于错误类型的区分有不同的最佳实践。我们将学习Go、Java、Ruby和Python。

› Go

在Go中执行此操作的惯用方法是导出API中的函数,这些函数可以检查抛出错误的属性。

package http

type httpError struct {
  retryable bool
}

func IsRetryable(err error) bool {
  httpError, ok := err.(httpError)
  return ok && httpError.retryable
}

然后,这样使用:

package main

func main() {
  res, err := http.Get("https://example.com")
  if err != nil {
    if http.IsRetryable(err) {
      // retry
    } else {
      // bail
    }
  }

这个想法扩展到错误可能具有的任何属性。任何您觉得API用户可能想做决定的内容都应该以这种方式暴露。

› Java

在Java中实现这一点的机制更为明确:自定义异常类型。

public class HttpException extends Exception {
  private final boolean retryable;

  private HttpException(String msg, Throwable cause, boolean retryable) {
    super(msg, cause);
    this.retryable = retryable;
  }

  public boolean isRetryable() {
    return this.retryable;
  }
}

这样使用它:

public final class Main {
  public static void main(String... args) {
    Response res;
    try {
      res = Http.get("https://example.com");
    } catch (HttpException e) {
      if (e.isRetryable()) {
        // retry
      } else {
        // bail
      }
    }
  }
}

› Python

Python中的情况类似。

class Error(Exception):
  pass

class HttpError(Error):
  def __init__(self, message, retryable):
    self.message = message
    self.retryable = retryable

  def is_retryable(self):
    return self.retryable

And using it:

try:
  res = Http.get("https://example.com")
except HttpError as err:
  if err.is_retryable():
    # retry
  else:
    # bail

编写从Exception扩展的通用Error类是编写Python库时的常见做法。它允许API的用户根据自己的意愿编写catch-all错误处理。

› Ruby

然后是 Ruby。

class HttpError < StandardError
  def initialize message, retryable
    @retryable = retryable
    super(message)
  end

  def is_retryable
    @retryable
  end
end

这样使用它:

begin
  res = Http.get("https://example.com")
rescue HttpError => e
  if e.is_retryable
    # retry
  else
    # bail

和 Python 基本相同。

› 结语

不要忽视您的错误消息。他们通常是用户与你编码的第一次接触,如果这很糟糕,他们会感到沮丧。

您希望让代码的用户能够灵活地以对他们最有意义的方式处理错误。使用上述方法,尽可能多地向他们提供有关情况的信息。

我有心去接触Rust和函数式语言,不过还没有实际接触。他们的错误处理方法与上述方法有很大不同。如果你知道其他语言中的好模式,我很乐意在评论中听到它们。