谁移动了我的错误代码?向Golang GraphQl Server添加错误类型
#编程 #api #go #graphql

几个月前,我们在Otterize进行了一次迁移许多API的旅程,包括我们的后端服务和Web应用程序使用的API之间使用的API到GraphQL。虽然我们享受了许多GraphQl功能,但我们沿着一些有趣的挑战,需要创造性的解决方案。

就个人而言,我发现我们使用GraphQl的错误和错误处理机制的冒险很着迷。考虑到GraphQl的知名度,我不希望GraphQl标准错过这一非常基本的东西

错误代码在哪里?

当您的代码遇到问题API调用时会发生什么?来自REST,我们都用来HTTP错误代码作为识别错误并相应采取措施的标准方法。例如,当一个服务称为另一个服务并收到错误时,它可以通过创建(如果适用)(如果适用)丢失资源来处理404 Not Found,而400 Bad Request错误会中止执行并返回适合客户端的错误消息。

现在,让我们看看GraphQl中的错误。这是一个非常基本的错误结构,由2个部分组成。第一个是“message",这是为人类用户设计的文本错误消息。第二个是"path",它从返回错误的查询中描述了字段路径。你能看到缺少什么吗?

{
  "errors": [
    {
      "message": "user Tobi was not found",
      "path": ["getUser"]
    }
  ]
}

简单的GraphQl错误

的示例

GraphQL中没有错误代码。它可能不会打扰您,因为人类阅读错误,但缺乏错误代码使得很难开发客户端代码,以识别和处理从服务器收到的错误。

我们通过在返回的错误消息中搜索特定单词来识别错误开始,但是很明显,这不是永久解决方案。服务器端上错误消息的任何小更改都可能使客户端类型的标识失败。

掩盖意外错误 - 在HTTP中显而易见,而在GraphQL中不多

可能最烦人的HTTP错误代码是500 Internal Server Error,因为它没有真正提供任何有用的信息。但这是关于您的应用程序信息安全性最重要的一个错误代码 - 换句话说,缺乏信息是有意的。 HTTP Frameworks掩盖了任何意外错误并返回HTTP 500 Internal Server Error,在此过程中,还掩盖了可能是错误消息一部分的敏感信息。

事实证明,

GraphQl的规格并未指定服务器应如何处理内部错误,而将其完全放在了Frameworks创建者的选择中。以我们的Golang GraphQl选择框架-gqlgen。它没有区分故意和意外错误:在错误消息中,所有错误都返回到客户端。通常包含敏感信息(例如网络详细信息和内部URI)的内部错误,如果没有被程序员手动捕获,则会轻松泄漏给客户。

{
  "errors": [
    {
      "message": "Post \"http://credential-factory:18081/internal/creds/env\": dial tcp credential-factory:18081: connect: connection refused",
      "path": ["createEnvironment"]
    }
  ]
}

通过GraphQL Server泄漏了未经处理的内部错误的模拟。

和gqlgen并不孤单。我们发现了其他几个GraphQl框架,这些框架不采取自己解决这个问题。广泛使用的GraphQl Server实现(例如graphql-go/graphql和Python的graphene)具有默认情况下公开意外错误消息的差距。

考虑到这两个要点,很明显,要完成我们向GraphQL的搬迁,我们需要找到一些添加错误类型的方法。一方面,我们将有一种可靠的方法来识别客户端代码中的错误。另一方面,我们可以在服务器端捕获意外错误,并从客户端隐藏他们的消息。

我们如何将错误类型添加到GraphQL?

我们开始研究可能的解决方案,并遇到了人们解决同样问题的各种方式,但是至少对于我们来说,其中许多似乎不便。然后,我们读取GraphQL errors spec,并了解到错误具有一个名为“extensions"的可选字段 - 一个非结构化的键值映射,可用于将任何其他信息添加到错误中。他们甚至使用一个称为“code”的键,该密钥包含one of their examples中的错误代码,但我们没有看到任何其他信息。 (后来,我认为它是从阿波罗(Apollo)拿走的。)

知道这一点,我们提出了一个计划,将错误代码作为值添加了错误的“extensions”映射。例如,这是新的“extensions”字段的同样错误:

{
  "errors": [
    {
      "message": "User Tobi not found",
      "path": [
        "getUser"
      ]
      "extensions": {
        "errorType": "NotFound"
      }
    }
  ]
}

挖掘GQLGEN的来源,我们发现GQLGEN GraphQl Server使用扩展键"code"报告解析和模式验证错误的错误代码。

{
  "error": {
    "errors": [
      {
        "message": "Cannot query field \"userDetails\" on type \"Query\".",
        "locations": [
          {
            "line": 2,
            "column": 3
          }
        ],
        "extensions": {
          "code": "GRAPHQL_VALIDATION_FAILED"
        }
      }
    ]
}

模式验证错误的示例。注意``代码键和错误代码'' 在扩展名下,由GQLGEN GraphQl Server本身添加。

不幸的是,没有内置的方法可以将GQLGEN的错误代码扩展到其他方面。我们考虑使用相同的"code"键作为我们的自定义错误代码,但最终,我们更喜欢坚持使用单独的“errorType”键,以避免使用GQLGEN的错误处理机构的潜在碰撞。

在此博客文章中工作时,我了解到Apollo Server是最受欢迎的GraphQl Server用于打字稿,使用类似的方法将错误代码添加到GraphQl。它甚至可以让您add custom errors。希望有一天其他GraphQl Server项目将遵循它们。在此之前,我们有很强的迹象表明我们采取了正确的方法。

我们针对类型GraphQl错误实现的GO实现

配备了我们建立的所有知识和计划,我们准备实施我们的错误解决方案。在本文的其余部分,我将在我们的应用中描述我们对该计划的实施。

定义我们的应用程序标准错误代码

首先,我们列出了我们想要拥有的所有错误代码。我们从曾经在REST中使用的HTTP错误代码开始,并将它们放在GraphQl枚举中。将错误代码放在模式中不是强制性的,但是它使得在服务器和客户端侧上都更容易引用相同的错误类型。

enum ErrorType {
  InternalServerError
  NotFound
  BadRequest
  Forbidden
  Conflict
}

错误代码模式。我们将其放在一个专用的架构文件中 “ errors.graphql”。

运行go generate后,GQLGEN生成了model包,其中带有来自错误代码的变量。下一步是创建一个新的typedError结构,该结构将错误类型与应返回客户端返回的错误类型配对。

package typederrors

type typedError struct {
    err       error
    errorType model.ErrorType  // error types are auto-generated from the schema
}

func (g typedError) Error() string {
    return g.err.Error()
}

func (g typedError) Unwrap() error {
    return g.err
}

func (g typedError) ErrorType() model.ErrorType {
    return g.errorType
}

// We have such a function for each of the types
func NotFound(messageToUserFormat string, args ...any) error {
    return &typedError{err: fmt.Errorf(messageToUserFormat, args...), errorType: model.ErrorTypeNotFound}
}

func InternalServerError(messageToUserFormat string, args ...any) error {
    return &typedError{err: fmt.Errorf(messageToUserFormat, args...), errorType: model.ErrorTypeInternalServerError}
}

// ...

然后,我们搜索了服务器代码库中的错误,并用适当的键入错误替换了本机GO错误,例如fmt.Errorf("user %s not found", userName),在这种情况下为typederrors.NotFound("user %s not found", userName)

与GraphQl Server集成

接下来,我们需要使我们的GraphQl Server处理我们应用程序的GraphQl解析器返回的键入错误,提取错误代码,然后将它们连接到扩展图映射。使用GQLGEN的方法是实现ErrorPresenter,该钩函数可让您在发送到客户端之前修改错误。

type TypedError interface {
    error
    ErrorType() model.ErrorType
}

// presentTypedError is a helper function that converts a TypedError to *gqlerror.Error
// and adds the error type to the extensions field
func presentTypedError(ctx context.Context, typedErr TypedError) *gqlerror.Error {
    presentedError := graphql.DefaultErrorPresenter(ctx, typedErr)
    if presentedError.Extensions == nil {
        presentedError.Extensions = make(map[string]interface{})
    }
    presentedError.Extensions["errorType"] = typedErr.ErrorType()
    return presentedError
}

// GqlErrorPresenter is a hook function for the gqlgen's GraphQL server, that handle
// TypedErrors and adds the error type to the extensions field.
func GqlErrorPresenter(ctx context.Context, err error) *gqlerror.Error {
    var typedError TypedError
    isTypedError := errors.As(err, &typedError)
    if isTypedError {
        return presentTypedError(ctx, typedError)
    }
    return graphql.DefaultErrorPresenter(ctx, err)
}

gqlerrorpresenter函数是我们的ERRORPRESENTER的实现 钩。

func main() {
  /// ...
  // Create a GraphQL server and make it use our error presenter
  srv := handler.NewDefaultServer(server.NewExecutableSchema(conf))
  srv.SetErrorPresenter(server.GqlErrorPresenter)
  /// ...
}

将我们的新错误主持人连接到GraphQl Server中。

一旦我们的新ErrorPresenter被绑定到GraphQl Server中,现在处理了提出的键入错误,并且它们的类型将暴露于"errorType" Extensions字段下的客户端。

{
  "errors": [
    {
      "message": "User Tobi not found",
      "path": ["updateUser"],
      "extensions": {
        "errorType": "NotFound"
      }
    }
  ]
}

当服务器返回键入错误时,GraphQL错误报告了。

掩盖非型错误与InternalServerror

为了防止埋在错误消息中埋藏的敏感信息的泄漏,我们采用了HTTP服务器的错误处理行为。我们没有返回非符合错误的错误,而是记录它们并返回键入的InternalServerError。鉴于键入错误,仅需要对ErrorPresenter的更改。

func GqlErrorPresenter(ctx context.Context, err error) *gqlerror.Error {
  var typedError TypedError
  isTypedError := errors.As(err, &typedError)
  if isTypedError {
    return presentTypedError(ctx, typedError)
  }
  // New code for masking sensitive error messages starts here
  var gqlError *gqlerror.Error
  if errors.As(err, &gqlError) && errors.Unwrap(gqlError) == nil {
    // It's a GraphQL schema validation / parsing error generated by the server itself,
    // error message should not be masked
    return graphql.DefaultErrorPresenter(ctx, err)
  }
  // Log original error and return InternalServerError instead
  logrus.WithError(err).Error("Custom GraphQL error presenter got an unexpected error")
  return presentTypedError(ctx, typederrors.InternalServerError("internal server error").(TypedError))
}

``gqlerrorpresenter''带有新的添加剂,该添加剂替代了不符合错误的错误 与``internalServerror''

{
  "errors": [
    {
      "message": "internal server error",
      "path": ["updateUser"],
      "extensions": {
        "errorType": "InternalServerError"
      }
    }
  ]
}

这就是更改后将向客户端出现未型错误的方式。

处理客户端的错误

在服务器端完成了我们的工作后,是时候获得好处并使用错误代码在客户端正确处理错误了。首先,我们需要错误代码GraphQl枚举以GO代码生成。我们通常使用GenQlient生成客户端代码,但是在这种情况下,这是不可能的,因为任何查询都不是枚举枚举。我们通过运行GQLGEN服务器端代码生成工具并仅保留生成的错误枚举来解决此问题。

schema:
  - "../../../graphql/errors.graphql"

model:
  filename: models_gen.go
  package: gqlerrors

gqlgen.yml

package gqlerrors

//go:generate go run github.com/99designs/gqlgen@v0.17.13
// we only need models_gen for the enum, so we delete the server code
//go:generate rm generated.go

生成

我们生成了错误代码枚举后,我们可以在同一软件包中编写一个简单的函数,该函数从genqlient错误对象中提取错误代码:

package gqlerrors

import (
    "github.com/sirupsen/logrus"
    "github.com/vektah/gqlparser/v2/gqlerror"
)

func GetGQLErrorType(err error) ErrorType {
    if errList, ok := err.(gqlerror.List); ok {
        gqlerr := &gqlerror.Error{}
        if errList.As(&gqlerr) && gqlerr.Extensions != nil {
            errorTypeString, isString := gqlerr.Extensions["errorType"].(string)
            if isString {
                return ErrorType(errorTypeString)
            }
        }
    }
    return ""
}

就是这样!我们准备编写代码来标识不同的错误代码并适当处理不同的错误。


package main

func main() {
    // ...
    if err != nil {
        if gqlerrors.GetGQLErrorType(err) == gqlerrors.ErrorTypeNotFound {
            // do something to handle the NotFound error
        } else {
            panic(err)
        }
    }
    // ...
}

结论

GraphQl是一个很棒的平台,但是缺乏标准化的错误代码是一个真正的缺点。尽管Apollo的GraphQl Server解决了它,但不幸的是,许多其他GraphQl服务器尚未解决它,包括我们的选择。

通过定义我们自己的错误代码并将它们集成到GraphQl Server的S errorperSenter,我们可以轻松地识别客户端的错误并处理它们。此外,我们防止敏感的内部错误消息被发送给客户并保持我们信息安全的完整性。

您可以查看example project,以查看我们在小型GO项目中的实现方式,以及错误类型如何影响客户的行为。这应该有助于了解所有摘要在实际的工作代码用例中聚集在一起,并为缺失的错误代码问题做出一个很好的解决方案。