Error codes in API design

There are many important aspects to consider when designing APIs, regardless of those being for public or private access. Certain qualities will hopefully make an API a joy to work with. Or a major pain.

Error codes can help with API usage experience for both developers and programs. They're not complicated to implement and bring a sense of uniformity and clarity to the error responses returned by an API.

Besides, with error codes, client applications can easily show consistent (and relevant) error messages to end-users and work with translations.

Of course, it doesn't help if different endpoints return different error structures, even if an error code is present somewhere in there.

My ideas here are quite simple (as the subject is not complex anyways):

Define a single error response structure

and expose it in your API docs:

openapi: 3.0.0
info:
  version: 1.0.0
  title: Sample API
  description: |
    ## API for Things and Stuff
    ### Errors (4xx-500 range status codes)
    Error responses also always follow their structure, exposing a message and a code.
     The message is indicative and most likely more relevant for development than end-users.
    Codes are more appropriate for client applications to implement error handling and error message translations.
    
    ```
    {
      "message": "The request route does not exist",
      "code": "route_not_found"
    }
    ```

Create human-readable error codes

Create human-readable error codes that programs can effortlessly parse:

  • method_not_allowed: The request HTTP method is not allowed in this server
  • payload_parse: Request body payload could not be parsed
  • payload_size: Request body payload exceeds the maximum of bytes allowed

Error codes in Go

I've been looking for a decent way of implementing this in Go API servers for a while. This is because Go doesn't have enums as seen in C# or Java. After trying a few approaches, I ended up liking this one (let's see how long it lasts).

Here's a custom struct type to represent the format of all errors returned by the API. You can attach extra utility functions to it for custom JSON encoding, etc.

type AppError struct {
	message    string
	code       string
	statusCode int
}

func (e AppError) StatusCode() int {}

func (a AppError) MarshalJSON() ([]byte, error) {}

Declare errors:

var ErrorInvalidQueryParam = AppError{
	message:    "One of the query parameters is not valid",
	code:       "invalid_query_param",
	statusCode: http.StatusBadRequest,
}

Use them:

func main() {
	http.HandleFunc("/", hello)
	http.ListenAndServe(":4000", nil)
}

func hello(w http.ResponseWriter, _ *http.Request) {
	RespondError(w, ErrorInvalidQueryParam, nil)
}

Why does the title mention Go generators?!

Besides a very simple yet complete standard library, Go comes bundled with quite a few tools that make developers' lives easier.

One of those tools is go generate.

The main goal of generate is to write Go programs that write Go programs. Meta-programming!

This can be pretty useful for many things, such as generating mocks from interfaces. In addition, using generators to write code for us can help avoid many repetitive development tasks.

However, generators can be used for anything, really. In this case, I created a generator to parse some code and generate the markdown list of error codes to include in the API spec file.

Generating docs

As much as I would like to have an OpenAPI v3 spec generated from code/comments, at the moment, there seems to be no viable option in the Go ecosystem (there is for Swagger v2, though). Therefore, I've kept the spec file as part of the project and enforced an API-first development approach: API, tests, and then code.

Even if I used an OpenAPI generator, it would not generate the list of existing error codes anyway. And that's why I'm here.

In the description section (Markdown), I added two comments to signal where the generator should print the codes.

openapi: 3.0.0
info:
  version: 1.0.0
  title: Sample API
  description: |
    ## API for Things and Stuff
    <!-- ERROR_GENERATOR_START -->
    <!-- ERROR_GENERATOR_END -->

In the file where the AppError declarations are kept, we add a special comment that tells go generate to run a generator on this file:

//go:generate go run errorgen.go
package main

type AppError struct {
	message    string
	code       string
	statusCode int
}

Now for the implementation of the actual generator. We need to use go/parser, to read the file in question and parse the Go code in it, so we can implement some logic that analyzes the abstract syntax tree (AST) and extracts what we need: the error code values:

fset := token.NewFileSet()
file, err := parser.ParseFile(fset, path.Join(cwd, os.Getenv("GOFILE")), nil, parser.ParseComments)

Now the code needs to iterate all relevant top-level declarations (which are the var declarations such as ErrorInvalidQueryParam). Error codes and respective messages are kept in the errs map:

errs := map[string]string{}

// iterate top level declarations
for _, d := range file.Decls {
  switch decl := d.(type) {
  // we're interested in "generic declaration" nodes, which represents variable declaration among other things
  case *ast.GenDecl:
    // iterate over Specs, which are declarations
    for _, spec := range decl.Specs {
      analyzeSpec(spec, errs)
    }
  }
}

analyzeSpec looks at a node representing a constant or variable declaration and runs extractFields if it the node is a var of type AppError:

func analyzeSpec(spec ast.Spec, errs map[string]string) {
	switch spec := spec.(type) {
	// we're interested in ValueSpec nodes (represent constant or variable declaration)
	case *ast.ValueSpec:
		// checking if the node is a composite literal of type AppError (AppError{...})
		if len(spec.Values) == 1 && spec.Values[0].(*ast.CompositeLit).Type.(*ast.Ident).Name == "AppError" {
			code, message := extractFields(spec.Values[0].(*ast.CompositeLit).Elts)
			errs[code] = message
		}
	}
}

extractFields receives a list of expressions that are part of a composite literal (the literal usage of AppError{}). In this case, these are the struct fields, and we want the values of the fields code and message:

func extractFields(elts []ast.Expr) (string, string) {
	var code string
	var message string

	// iterate over this composite literal elements (the struct fields: message, code, statusCode)
	for _, el := range elts {
		kv, ok := el.(*ast.KeyValueExpr)
		if !ok {
			continue
		}
		// extract field name
		key := kv.Key.(*ast.Ident).Name

		// extract the value for code and message
		switch key {
		case "code":
			code = kv.Value.(*ast.BasicLit).Value
		case "message":
			message = kv.Value.(*ast.BasicLit).Value
		}
	}

	return code, message
}

The last part is not so interesting. The remaining function (generateOpenapiErrorCodes) opens the openapi.yaml file, iterates its lines, identifies the marker comments, and writes the lines over with a bit of logic to a new buffer that will replace the file contents in the end.

I encourage you to look at the full code here, specifically this file.