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 serverpayload_parse
: Request body payload could not be parsedpayload_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.
Related Articles
Deploying NestJS Microservices to AWS ECS with Pulumi IaC
Let’s see how we can deploy NestJS microservices with multiple environments to ECS using...
Read moreWhat is CI/CD? A Guide to Continuous Integration & Continuous Delivery
Learn how CI/CD can improve code quality, enhance collaboration, and accelerate time-to-ma...
Read moreBuild a Powerful Q&A Bot with LLama3, LangChain & Supabase (Local Setup)
Harness the power of LLama3 with LangChain & Supabase to create a smart Q&A bot. This guid...
Read moreDemystifying AI: The Power of Simplification
Unleash AI's power without the hassle. Learn how to simplify complex AI tasks through easy...
Read more