Micro Handlers

The Synadia team has a nice package for creating micro services. In the Go client, this is the micro package.

The beautiful thing about this setup is it follows the same pattern as the std library HTTP handlers. The micro package has an interface defined as Handler(micro.Request). Functions can be wrapped with micro.HandlerFunc() just like you can wrap an HTTP handler with http.HandlerFunc().

This allows for the same midleware layouts as you normally have in HTTP servers.

Errors

Handlers accept a micro.Request which contains methods to retrieve the data on the request. A request also contains an Error(code, description string, data []byte, opts ...RespondOpt) error method. Similar to http.Error() this will return an error to the caller.

Returning errors like this however can be burdensome depending on the number of handlers and number of possible errors your application can return. Luckily, since the NATS team has put a lot of thought into this design, we can wrap handlers and return errors easily.

Error Handlers

Let’s implement a custom error handler that we can just return errors to and have it handle them based on error type.

Types and Methods

type HandlerWithErrors func(micro.Request) error

type ClientError struct {
	Code    int
	Details string
}

func (c ClientError) Error() string {
	return c.Details
}

func (c *ClientError) Body() []byte {
	return []byte(fmt.Sprintf(`{"error": "%s"}`, c.Details))
}

func (c *ClientError) CodeString() string {
	return strconv.Itoa(c.Code)
}

func (c ClientError) As(target any) bool {
	_, ok := target.(*ClientError)
	return ok
}

func NewClientError(err error, code int) ClientError {
	return ClientError{
		Code:    code,
		Details: err.Error(),
	}
}

Here we define a few things. A function type which we can use to define our specific handler type. A struct called ClientError which will denote client errors. Some methods on ClientError, one being Error() string which satisfies the error interface. And finally a function to return a new ClientError.

Middleware

func ErrorHandler(h HandlerWithErrors) micro.HandlerFunc {
	return func(r micro.Request) {
		err = h(r)
		if err == nil {
			return
		}

	  var ce ClientError
	  if errors.As(err, &ce) {
	  	r.Error(ce.CodeString(), http.StatusText(ce.Code), ce.Body())
	  	return
	  }

	  logger.Error(err)

	  r.Error("500", "internal server error", []byte(`{"error": "internal server error"}`))
	}
}

Here we define the function that we can wrap our handlers with. Here we return a micro.HandlerFunc which satisfies the micro.Handler interface. In our code we can then just call svc.AddEndpoint("test", ErrorHandler(myHandler))

In our handlers, we simply need to return the error. The middleware will check the error type and handle it appropriately.

Sample Code

Let’s take a look at a sample program to do all of this. Start the service with the code below and send some sample requests.

nats req "test" '{"name": "Pete"}': This should return a 500 error

nats req "test" '{"name": "John"': This should also return a 400 error

nats req "test" '{"name": "John"}': This should simply return “John”

package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"log"
	"net/http"
	"os"
	"os/signal"
	"strconv"
	"syscall"

	"github.com/nats-io/nats.go"
	"github.com/nats-io/nats.go/micro"
)

func main() {
	nc, err := nats.Connect(nats.DefaultURL)
	if err != nil {
		log.Fatal(err)
	}

	ms, err := micro.AddService(nc, micro.Config{
		Name:        "test",
		Version:     "0.0.1",
		Description: "Example",
	})
	if err != nil {
		log.Fatal(err)
	}

	ms.AddEndpoint("test", ErrorHandler(myHandler))

	sigTerm := make(chan os.Signal, 1)
	signal.Notify(sigTerm, os.Interrupt, syscall.SIGINT, syscall.SIGTERM)

	sig := <-sigTerm
	log.Printf("received signal: %s\n", sig)
	ms.Stop()
}

type Person struct {
	Name string `json:"name"`
}

func myHandler(r micro.Request) error {
	var person Person
	if !json.Valid(r.Data()) {
		return NewClientError(fmt.Errorf("invalid JSON"), 400)
	}

	if err := json.Unmarshal(r.Data(), &person); err != nil {
		return NewClientError(err, 400)
	}

	if person.Name == "Pete" {
		return fmt.Errorf("this is a server error")
	}

	r.Respond(r.Data())

	return nil
}

type HandlerWithErrors func(micro.Request) error

type ClientError struct {
	Code    int
	Details string
}

func (c ClientError) Error() string {
	return c.Details
}

func (c *ClientError) Body() []byte {
	return []byte(fmt.Sprintf(`{"error": "%s"}`, c.Details))
}

func (c *ClientError) CodeString() string {
	return strconv.Itoa(c.Code)
}

func (c ClientError) As(target any) bool {
	_, ok := target.(*ClientError)
	return ok
}

func NewClientError(err error, code int) ClientError {
	return ClientError{
		Code:    code,
		Details: err.Error(),
	}
}

func ErrorHandler(h HandlerWithErrors) micro.HandlerFunc {
	return func(r micro.Request) {
		err := h(r)
		if err == nil {
			return
		}

		var ce ClientError
		if errors.As(err, &ce) {
			r.Error(ce.CodeString(), http.StatusText(ce.Code), ce.Body())
			return
		}

		log.Println(err)

		r.Error("500", "internal server error", []byte(`{"error": "internal server error"}`))
	}
}