If you've been following this series, your directory structure should look something like this:
Handling Errors
In order to standarize the error response, it is recommended to create a helper function to handle it. If you see the Get function, one of its parameters is a handler function, so lets create one
file: internal/router/common.go
package router
import "net/http"
type ErrorResponse func(w http.ResponseWriter, r *http.Request) error
func HandleErrors(h ErrorResponse) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
}
func HomeRoute(w http.ResponseWriter, r *http.Request) error {
_, err := w.Write([]byte("Hello world"))
return err
}
Now lets wrap the Get function in our error response
file: internal/router/router.go
...
func (s *service) Router() error {
r := chi.NewRouter()
r.Get("/", HandleErrors(HomeRoute))
port := fmt.Sprintf(":%d", s.port)
slog.Info("Starting server", "port", port)
return http.ListenAndServe(port, r)
}
Now we have a centralized way to handle any error that we find in our project.
Returning JSON response
It is common that an API returns a JSON response, this will give the frontend a standarize way to receive and process the information provided.
file: internal/router/common.go
package router
import (
"encoding/json"
"net/http"
)
...
func HomeRoute(w http.ResponseWriter, r *http.Request) error {
return JSONResponse(w, http.StatusOK, map[string]any{"message": "Hello world"})
}
func JSONResponse(w http.ResponseWriter, code int, data map[string]any) error {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
return json.NewEncoder(w).Encode(data)
}
Health endpoint
Now lets create a health endpoint, which will be in charge of checking that all the dependencies of our project are working correctly
file: internal/router/health.go
package router
import "net/http"
func HealthRoute(w http.ResponseWriter, r *http.Request) error {
return JSONResponse(w, http.StatusOK, map[string]any{"status": "ok"})
}
file: internal/router/router.go
...
func (s *service) Router() error {
...
r.Get("/health", HandleErrors(HealthRoute))
...
}
Database connection
Now we have all the components so we can connect to a database and check if everything is correctly working, first we need to install the package to connect to a PostgreSQL database
go get github.com/jackc/pgx/v5
Next we will create a database package where we will keep all the database interaction function
file: internal/database/database.go
package database
import (
"database/sql"
"os"
_ "github.com/jackc/pgx/v5"
)
type Service interface{}
type service struct {
DB *sql.DB
}
func New() (Service, error) {
conn, err := sql.Open("pgx", os.Getenv("DATABASE_URL"))
if err != nil {
return nil, err
}
return &service{
DB: conn,
}, nil
}
file: internal/router/router.go
...
import (
...
"github.com/alcb1310/bookstore/internal/database"
...
)
type service struct {
port uint16
db database.Service
}
func New(port uint16, db database.Service) *service {
return &service{
port: port,
db: db,
}
}
...
file: cmd/api/main.go
...
import (
...
"github.com/alcb1310/bookstore/internal/database"
...
)
func main() {
...
db, err := database.New()
if err != nil {
slog.Error("Error connecting to database", "error", err)
panic(err)
}
s := router.New(port, db)
...
}
Updating the Health endpoint
Now that we are connected to the database we need to have the capacity to check if the database is still available
file: internal/database/database.go
...
type Service interface {
HealthCheck() error
}
...
func (s *service) HealthCheck() error {
return s.DB.Ping()
}
file: internal/router/health.go
package router
import "net/http"
func (s *service) HealthRoute(w http.ResponseWriter, r *http.Request) error {
if err := s.db.HealthCheck(); err != nil {
return err
}
return JSONResponse(w, http.StatusOK, map[string]any{"status": "ok"})
}
file: internal/router/router.go
...
func (s *service) Router() error {
r := chi.NewRouter()
r.Get("/", HandleErrors(HomeRoute))
r.Get("/health", HandleErrors(s.HealthRoute))
port := fmt.Sprintf(":%d", s.port)
slog.Info("Starting server", "port", port)
return http.ListenAndServe(port, r)
}
Handling errors
At the moment, we are returning to much information to the user, information that should be logged, but not something to show the final user. First we will need to implement an error structure for the api
file internal/interfaces/apierror.go
package interfaces
type APIError struct {
Code int
Msg string
OriginalError error
}
func (e *APIError) Error() string {
return e.Msg
}
file internal/router/common.go
package router
import (
...
"errors"
"log/slog"
"github.com/alcb1310/bookstore/internal/interfaces"
)
type ErrorResponse func(w http.ResponseWriter, r *http.Request) error
func HandleErrors(h ErrorResponse) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
if e, ok := errors.AsType[*interfaces.APIError](err); ok {
slog.Error("API error", "code", e.Code, "error", e.OriginalError)
_ = JSONResponse(w, e.Code, map[string]any{"error": e.Msg})
return
}
slog.Error("Internal server error", "error", err)
_ = JSONResponse(w, http.StatusInternalServerError, map[string]any{"error": err.Error()})
}
}
}
...
Finally, errors should be coded where it occurs, so we edit the health check function
file: internal/database/database.go
package database
import (
"context"
"database/sql"
"errors"
"fmt"
"net/http"
"os"
"github.com/alcb1310/bookstore/internal/interfaces"
"github.com/jackc/pgx/v5/pgconn"
_ "github.com/jackc/pgx/v5/stdlib"
)
...
func (s *service) HealthCheck() error {
if err := s.DB.Ping(); err != nil {
if e, ok := errors.AsType[*pgconn.PgError](err); ok {
switch e.Code {
case "3D000":
return &interfaces.APIError{
Code: http.StatusGatewayTimeout,
Msg: "Database is not available",
OriginalError: e,
}
case "28000":
return &interfaces.APIError{
Code: http.StatusBadGateway,
Msg: "Database is not available",
OriginalError: e,
}
default:
return &interfaces.APIError{
Code: http.StatusInternalServerError,
Msg: "Database is not available",
OriginalError: e,
}
}
}
if _, ok := errors.AsType[*pgconn.ConnectError](err); ok {
return &interfaces.APIError{
Code: http.StatusServiceUnavailable,
Msg: "Database is not available",
OriginalError: err,
}
}
return err
}
return nil
}
With this we are returning proper errors to the user, with specific Http Error Codes according on what have happened to the database connection, but, if we loose network connection, then the application hangs up. To solve this we have the context api and the PingContext function where we can establish a time limit to a process, so we will wait at most for 5 seconds before an error happens
package database
import (
...
"time"
...
)
...
func (s *service) HealthCheck() error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := s.DB.PingContext(ctx); err != nil {
...
}
return nil
}
Adding middleware
Middleware are functions we run before the route itself runs, there are plenty of things we can use a middleware for, such as authentication and authorizatin, logging, among others, so lets add some to our application
What every of the middleware we add here does can be found in the Go chi middleware documentation page
To enable the httprate middleware, we will need to add its package
go get github.com/go/chi/httprate
file internal/router/router.go
package router
import (
...
"time"
...
"github.com/go-chi/httprate"
)
...
func (s *service) Router() error {
r := chi.NewRouter()
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.CleanPath)
r.Use(middleware.Recoverer)
r.Use(httprate.LimitByIP(100, 1*time.Minute))
...
}
To test the rate limiter, I've created a short script that is part of the project's repo
file: cmd/scripts/rate
!# /bin/bash
source .env
start=$EPOCHREALTIME
for i in {1..103}
do
echo "Request $i"
curl http://localhost:$PORT
done
end=$EPOCHREALTIME
echo "Elapsed: $(echo "$end - $start" | bc) seconds"

Top comments (0)