- Go
- API
Who moved my error codes? Adding error types to your GoLang GraphQL Server
Discover Otterize's move to GraphQL, exploring the challenges and creative solutions in transitioning various APIs, from back-end services to the web app, and gaining insights into the complexities of this journey.
Written By
Amit ItzkovitchPublished Date
Aug 16 2022Read Time
11 minutesIn this Article
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"]
}
]
}
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"]
}
]
}
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"
}
}
]
}
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"
}
}
]
}
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
}
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}
}
// ...
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)
}
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)
/// ...
}
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"
}
}
]
}
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))
}
The GqlErrorPresenter
with the new addition that replaces non-typed errors
with InternalServerError
{
"errors": [
{
"message": "internal server error",
"path": ["updateUser"],
"extensions": {
"errorType": "InternalServerError"
}
}
]
}
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
gqlgen.yml
package gqlerrors
//go:generate go run github.com/99designs/[email protected]
// we only need models_gen for the enum, so we delete the server code
//go:generate rm generated.go
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 ""
}
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)
}
}
// ...
}
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.
In this Article
Like this article?
Sign up for newsletter updates
Resource Library
Read blogs by Otis, run self-paced labs that teach you how to use Otterize in your browser, or read mentions of Otterize in the media.
- Kubernetes
First Person Platform E04 - Ian Evans on security as an enabler for financial institutions
The fourth episode of First Person Platform, a podcast: platform engineers and security practitioners nerd out with Ori Shoshan on access controls, Kubernetes, and platform engineering.