Lessons

API Rate Limiter for Go

In this post, we will step through setting up a rate limiter for inbound API requests. The standard use case, you are hosting an API and want to keep API requests below a certain threshold. For example, 5 requests per second. If the user crosses over that rate limit, they receive a HTTP 429 too many request code.

We will do this in Golang, use Tollbooth and it will be done for an API built with Gin. I’m going to assume you have Go setup on your local machine. If you want to use boilerplate project, take a look at Getting Started with Go Boilerplate. All code for this tutorial can be found here.

Start by cloning a repo, or just creating a new directory. In that directory, initialise the project.

1 2 go mod init go mody tidy

Let’s install the dependencies:

1 2 go get github.com/didip/tollbooth go get github.com/gin-gonic/gin

We will create a folder called auth and within it session.go. This file will be a host our Session model which represents a single user session. I’m skipping over the session management and focusing just on the rate limit. In this sample, session will be both a user session and API key. The API key is just a session that doesn’t expire.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 package auth import ( "github.com/gin-gonic/gin" "time" ) type Session struct { ID string `json:"id"` UserEmail string `json:"userEmail"` ExpiryAt time.Time `json:"expiry_at"` } func GetSession(c *gin.Context) (Session, bool) { i, exists := c.Get("session") if !exists { return Session{}, false } session, ok := i.(Session) if !ok { return Session{}, false } return session, true }

Next create a folder called users and put a file called auth.go inside. This file will contain the code to extract the session from Authorization header. It will have a placeholder for where the verification would happen.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 package user import ( "demo-tollbooth/auth" "fmt" "github.com/gin-gonic/gin" "strings" "time" ) func ValidateAuth() gin.HandlerFunc { return func(c *gin.Context) { authToken := c.Request.Header.Get("Authorization") if authToken == "" { c.AbortWithStatus(403) return } authToken = strings.ReplaceAll(authToken, "Bearer ", "") session := auth.Session{ ID: authToken, } // TODO - Verify session is valid, THIS IS BEING SKIPPED //if session.ExpiryAt.Before(time.Now()) { if authToken == "" { // This is just a demo use case fmt.Println("Session expired") c.AbortWithStatus(403) return } c.Set("session", session) c.Next() } }

This middleware function above will be called before the rate limit. It allows us to confirm they API calls are coming from a valid API key, before we rate limit that key.

I separated auth and users. We want users to be able to perform user look ups when verifying access. We want other packages to pull Session information without inheriting users. In my opinion, this will reduce the risk of cyclical dependencies.

We will create a folder called middleware. This will be where we will be putting the API middleware. Within this folder, create a file called ratelimiter.go and the contents will be:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 package middleware import ( "demo-tollbooth/auth" "fmt" "github.com/didip/tollbooth" "github.com/didip/tollbooth/limiter" "github.com/gin-gonic/gin" "time" ) func LimitHandler(requestsPerSecond float64) gin.HandlerFunc { limit := tollbooth.NewLimiter(requestsPerSecond, &limiter.ExpirableOptions{DefaultExpirationTTL: time.Hour}) return func(c *gin.Context) { session, exists := auth.GetSession(c) if !exists { c.Next() return } httpErr := tollbooth.LimitByKeys(limit, []string{session.ID}) c.Writer.Header().Add("X-Rate-Limit-Limit", fmt.Sprintf("%.2f", limit.GetMax())) c.Writer.Header().Add("X-Rate-Limit-Duration", "1") if httpErr != nil { c.JSON(429, gin.H{}) c.Abort() } else { c.Next() } } }

This will grab the session from the Gin context. It will check if they’ve surpassed the rate limit (arg passed in). It will then either abort with a HTTP 429 code or continue to the next Gin function.

Next, we need a handler function to be called. For example purposes, create a folder called cars and add a file called handler.go .

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 package cars import ( "github.com/gin-gonic/gin" "time" ) type Handler struct { } func NewHandler() Handler { return Handler{} } func (h *Handler) CreateCar(c *gin.Context) { time.Sleep(1 * time.Second) // Simulate work c.JSON(201, gin.H{"message": "Car created"}) }

This will be the one function called from my API. It will pause for a second to simulate work then return a 201.

The last file is the main entry point. At the root of the project, create a file called main.go.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 package main import ( "demo-tollbooth/cars" "demo-tollbooth/middleware" "demo-tollbooth/user" "fmt" "github.com/gin-gonic/gin" ) const ( MaxRequestsPerSecond = 5 // 5 per second ) func main() { fmt.Println("Starting...") carsHandler := cars.NewHandler() router := gin.Default() router.Use(gin.Recovery()) carsAPI := router.Group("/cars") { carsAPI.POST("", user.ValidateAuth(), middleware.LimitHandler(MaxRequestsPerSecond), carsHandler.CreateCar) } router.Run(":7800") }

Don’t forget that the package is package main. We pul the rate limiter after validation, otherwise, it will always be skipped. You can always modify the ratelimiter.go to be this instead:

1 2 3 4 5 6 session, exists := auth.GetSession(c) if !exists { c.Abort() c.JSON(403, gin.H{}) return }

The request would return a 403. The validateAuth still needs to be first.

You can run this with:

1 go run ./...

The API will start on port 7800 (picked to not already be used).

You can make a single request with:

1 curl -X POST http://localhost:7800/cars -v

This API call will return a 403 since no Authorization details have been provided.

1 curl -X POST http://localhost:7800/cars --header "Authorization: Bearer demo" -v

The response should be:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 * Trying 127.0.0.1:7800... * Connected to localhost (127.0.0.1) port 7800 (#0) > POST /cars HTTP/1.1 > Host: localhost:7800 > User-Agent: curl/7.87.0 > Accept: */* > Authorization: Bearer demo > * Mark bundle as not supporting multiuse < HTTP/1.1 201 Created < Content-Type: application/json; charset=utf-8 < X-Rate-Limit-Duration: 1 < X-Rate-Limit-Limit: 5.00 < Date: Tue, 25 Jul 2023 18:10:46 GMT < Content-Length: 25 < * Connection #0 to host localhost left intact {"message":"Car created"}

Our API is setup.

It’s important to understand that we are measuring the immediately upon receiving the request. It’s not 5 per second and waiting on the response, it’s a max of 5 requests received.

That’s it, thanks for reading!