DEV Community

Cover image for Who moved my error codes? Adding error types to your GoLang GraphQL Server
Tomer Greenwald for Otterize

Posted on • Originally published at otterize.com

Who moved my error codes? Adding error types to your GoLang GraphQL Server

A few months ago, we at Otterize went on a journey to migrate many of our APIs, including the ones used between our back-end services and the ones used by our web app, to GraphQL. While we enjoyed many GraphQL features, we faced along the way a few interesting challenges which required creative solutions.

Personally, I find our adventure with GraphQL’s errors and the error handling mechanism a fascinating one. Considering GraphQL’s popularity, I didn’t expect the GraphQL standard to miss this one very fundamental thing…

Where are the error codes?!

What happens when your code encounters a problem making an API call? Coming from REST, we’re all used to HTTP error codes as the standard way to identify errors and take action accordingly. For example, when a service called another service and receives an error, it may handle 404 Not Found by creating (if appropriate) the missing resource, while 400 Bad Request errors abort the execution and return a client-appropriate error message.

Now, let’s look at what errors look like in GraphQL. It’s a pretty basic error structure, which consists of 2 parts. The first is “message", which is a textual error message designed for human users. The second is "path", which describes the path of the field from the query that returned the error. Can you see what is missing?

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

An example of a simple GraphQL error

There are no error codes in GraphQL. It might not disturb you much as a human reading the error, but the absence of error codes makes it really hard to develop client-side code that identifies and handles errors received from the server.

We started out by identifying errors by searching for specific words in the returned error message, but it was clear it is not a permanent solution. Any small change to the error message on the server side might fail the identification of the type on the client side.

Masking unexpected errors - obvious in HTTP, not so much in GraphQL

Probably the most annoying HTTP error code is 500 Internal Server Error, as it doesn’t really give any useful information. But this is the one error code that matters the most regarding your application’s information security — in other words, the lack of information is intentional. HTTP frameworks mask any unexpected error and return HTTP 500 Internal Server Error instead, in the process also masking any sensitive information that might have been part of the error message.

GraphQL’s spec, as it turns out, does not specify how servers should handle internal errors at all, leaving it entirely to the choice of the frameworks’ creators. Take for example our GoLang GraphQL framework of choice - gqlgen. It makes no distinction between intentional and unexpected errors: all errors are returned as-is to the client within the error message. Internal errors, which often contain sensitive information like network details and internal URIs, would leak to clients easily if not caught manually by the programmer.

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

A simulation of an unhandled internal error leaked through the GraphQL server.

And gqlgen is not alone in this. We found several more GraphQL frameworks that don’t take it upon themselves to address this problem. Widely used GraphQL server implementations, such as graphql-go/graphql and Python’s graphene, have the exact same gap of exposing messages of unexpected errors by default.

With these two points in mind, it was clear that to complete our move to GraphQL, we needed to find some way to add error types. For one thing, we would have a reliable way to identify errors in clients’ code. And for another, we could catch unexpected errors on the server side and hide their message from clients.

How can we add error types to GraphQL?

We started researching possible solutions and encountered various ways people took to solve the same problem, but many of those seemed inconvenient, at least for us. Then we read the GraphQL errors spec and learned that errors have an optional field called “extensions" — an unstructured key-value map that can be used to add any additional information to the error. They even use a key called “code” that contains what looks like an error code in one of their examples, but we didn’t see any further information. (Later, I figured it was taken from Apollo — see below.)

Knowing this, we came up with a plan of adding an “errorType” key to the error’s “extensions” map, with the error code as the value. For example, here is the same error with the new “extensions” field:

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

Digging into gqlgen’s sources, we discovered that the gqlgen GraphQL server uses the extension key "code" to report the error code of parsing and schema validation errors.

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

Example of a schema validation error. Note the “code” key and the error code under “extensions”, added by the gqlgen GraphQL Server itself.

Unfortunately, there is no built-in way to extend gqlgen’s error codes with additional ones. We considered using the same "code" key for our custom error codes, but eventually, we preferred sticking to our separate “errorType” key to avoid potential future collisions with gqlgen’s error handling mechanism.

While working on this blog post, I learned that Apollo Server, the most popular GraphQL server for typescript, uses a similar method for adding error codes to GraphQL. It even lets you add custom errors. Hopefully, someday other GraphQL server projects will follow them. Until then, we’ve got a strong indication we took the right approach.

Our Go implementation for typed GraphQL errors

Equipped with all the knowledge we’ve built up and our plan, we were ready to implement our error-typing solution. Throughout the rest of this post, I will be describing our implementation of that plan in practice, in our application.

Defining our application’s standard error codes

First, we listed all the error codes we would like to have. We started with the HTTP error codes we used to work with in REST and placed them in a GraphQL enum. Putting the error codes in the schema is not mandatory, but it makes it easier to refer to the same error types on both the server and client sides.

enum ErrorType {
  InternalServerError
  NotFound
  BadRequest
  Forbidden
  Conflict
}
Enter fullscreen mode Exit fullscreen mode

The error codes schema. We put it in a dedicated schema file called "errors.graphql".

After running go generate, gqlgen generated the model package with the variables from the error codes enum. The next step was to create a new typedError struct, which pairs an error with the error type that should be returned to the client.

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

// ...
Enter fullscreen mode Exit fullscreen mode

Then, we searched our server codebase for errors and replaced native go errors such as fmt.Errorf("user %s not found", userName) with the appropriate typed error, in this case typederrors.NotFound("user %s not found", userName)

Integrating with the GraphQL server

Next, we needed to make our GraphQL server handle the typed errors returned by our application’s GraphQL resolvers, extract the error codes, and attach them to the extensions map. The way to do that using gqlgen is to implement an ErrorPresenter, a hook function that lets you modify the error before it is sent to the client.

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)
}
Enter fullscreen mode Exit fullscreen mode

The GqlErrorPresenter function is our implementation of the ErrorPresenter hook.

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

Hooking our new error presenter into the GraphQL server.

Once our new ErrorPresenter is bound into the GraphQL server, raised typed errors are now processed and their type is exposed to the client under the "errorType" extensions field.

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

The GraphQL error reported when the server returns a typed error.

Masking non-typed errors with InternalServerError

In order to prevent the leaking of sensitive information buried inside error messages, we then adopted the error handling behavior of HTTP servers. Instead of returning non-typed errors, we log them and return the typed InternalServerError instead. Given the typed errors, it only requires a small change to the 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))
}
Enter fullscreen mode Exit fullscreen mode

The `GqlErrorPresenter` with the new addition that replaces non-typed errors with `InternalServerError`

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

This is how an untyped error will be presented to the client after the change.

Handling errors on the client side

Having finished our work on the server side, it was time to reap the benefits and use the error codes to handle the errors properly on the client side. First, we need the error codes GraphQL enum to be generated as Go code. We typically generate client-side code using genqlient, but in this case, it wasn’t possible because the error type enum isn’t referenced by any query. We solved this by running the gqlgen server-side code generation tool and keeping only the generated error enums.

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

model:
  filename: models_gen.go
  package: gqlerrors
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

generate.go

Once we generated the error codes enum, we could write a simple function in the same package that extracts the error code from the genqlient error object:

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 ""
}
Enter fullscreen mode Exit fullscreen mode

And that’s it! We are ready to write code that identifies the different error codes and handles the different errors appropriately.


package main

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

Conclusion

GraphQL is a great platform, but the absence of standardized error codes is a real shortcoming. Although it’s addressed by Apollo’s GraphQL server, it’s unfortunate that many other GraphQL servers have yet to address it, including our choice — gqlgen.

By defining our own error codes and integrating them with the GraphQL server’s ErrorPresenter, we can easily identify errors on the client side and handle them. In addition, we prevent sensitive internal error messages from being sent to clients and maintain the integrity of our information security.

You may check out the example project to see what our implementation looks like in a small Go project, and how error types affect the client’s behavior. This should help understand where all the snippets come together in an actual working code use case and make a great solution for the missing error codes problem.

Top comments (0)