Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions rate-limit-redis/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Redis Rate Limiter Middleware for Fiber
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


This recipe demonstrates how to build a custom rate limiter middleware in [Fiber](https://github.com/gofiber/fiber) using [Redis](https://redis.io/) as the backend.

The middleware limits incoming requests per IP address within a given time window (e.g., 10 requests per minute).

## 📦 Stack

- Go
- Fiber Web Framework
- Redis (go-redis v9)

## 📁 Project Structure

rate-limit-redis/
├── config/
│ └── redis.go # Initializes Redis client
├── handlers/
│ └── home.go # Example handler
├── middleware/
│ └── limiter.go # Rate limiting logic
├── main.go # Entry point


## 🚀 Getting Started

### 1. Run Redis locally
Make sure Redis is running on `localhost:6379`. You can use Docker:

```bash
docker run -p 6379:6379 redis
```
### 2. Run the project
go run main.go

### 3. Test it
You can hit the endpoint repeatedly:

```bash
curl http://localhost:8080/home
```

After 10 requests (within 60 seconds), you’ll receive:

```json
{
"error": "Too Many Requests"
}

```

⚙️ Configuration
You can modify rate limit logic by changing:
app.Use(RateLimiterMiddleware(redisClient, time.Minute, 10))


✨ Features
Rate limiting per IP

Redis-backed counter for persistence and performance

Retry-After header for graceful client handling

🙌 Contributing
Feel free to open issues or PRs if you'd like to improve this recipe!


🧠 Inspired by production needs where APIs require protection against brute-force abuse and overuse.
20 changes: 20 additions & 0 deletions rate-limit-redis/config/redis.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package config

import (
"fmt"

"github.com/redis/go-redis/v9"
)

var RedisClient *redis.Client

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using a global variable for the Redis client (RedisClient) makes the code harder to test and manage dependencies. Consider returning the client from RedisInit and passing it explicitly where needed.

Suggested change
var RedisClient *redis.Client
var redisClient *redis.Client


func RedisInit() {
RedisClient = redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "",
DB: 0,
Protocol: 2,
})

fmt.Println("Redis initialized")
}
Comment on lines +11 to +20
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Return a connection error instead of assuming success

RedisInit always prints “Redis initialized” even when Redis is unreachable.
Return an error after a PING, let callers decide how to react, and avoid silently booting with a broken cache.

-func RedisInit() {
-	RedisClient = redis.NewClient(&redis.Options{
+func RedisInit() error {
+	RedisClient = redis.NewClient(&redis.Options{
 		Addr:     "localhost:6379",
 		Password: "",
 		DB:       0,
 		Protocol: 2,
 	})
-
-	fmt.Println("Redis initialized")
+	if err := RedisClient.Ping(context.Background()).Err(); err != nil {
+		return fmt.Errorf("redis ping failed: %w", err)
+	}
+	fmt.Println("Redis initialized")
+	return nil
 }

Call-site changes required in main.go.
(Consider parameterising address/password via env vars as well.)

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func RedisInit() {
RedisClient = redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "",
DB: 0,
Protocol: 2,
})
fmt.Println("Redis initialized")
}
func RedisInit() error {
RedisClient = redis.NewClient(&redis.Options{
Addr: "localhost:6379",
Password: "",
DB: 0,
Protocol: 2,
})
if err := RedisClient.Ping(context.Background()).Err(); err != nil {
return fmt.Errorf("redis ping failed: %w", err)
}
fmt.Println("Redis initialized")
return nil
}
🤖 Prompt for AI Agents
In rate-limit-redis/config/redis.go lines 11 to 20, the RedisInit function
always prints "Redis initialized" without verifying the connection. Modify
RedisInit to perform a PING command after creating the client and return any
connection error instead of printing success unconditionally. Update the
function signature to return an error, and remove the print statement. This
allows callers to handle connection failures properly. Also, consider passing
Redis address and password as parameters or environment variables for
flexibility.

24 changes: 24 additions & 0 deletions rate-limit-redis/go.mod
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
module ratelimitfiber

go 1.23.1

require (
github.com/gofiber/fiber/v2 v2.52.8
github.com/redis/go-redis/v9 v9.10.0
)

require (
github.com/andybalholm/brotli v1.1.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/klauspost/compress v1.17.9 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/sys v0.28.0 // indirect
)
37 changes: 37 additions & 0 deletions rate-limit-redis/go.sum
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M=
github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gofiber/fiber/v2 v2.52.8 h1:xl4jJQ0BV5EJTA2aWiKw/VddRpHrKeZLF0QPUxqn0x4=
github.com/gofiber/fiber/v2 v2.52.8/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA=
github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/redis/go-redis/v9 v9.10.0 h1:FxwK3eV8p/CQa0Ch276C7u2d0eNC9kCmAYQ7mCXCzVs=
github.com/redis/go-redis/v9 v9.10.0/go.mod h1:huWgSWd8mW6+m0VPhJjSSQ+d6Nh1VICQ6Q5lHuCH/Iw=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g=
github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8=
github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
9 changes: 9 additions & 0 deletions rate-limit-redis/handlers/home.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package handlers

import (
"github.com/gofiber/fiber/v2"
)

func Home(c *fiber.Ctx) error {
return c.Status(fiber.StatusOK).SendString("This is home api")
}
22 changes: 22 additions & 0 deletions rate-limit-redis/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package main

import (
"ratelimitfiber/config"
"ratelimitfiber/middleware"
"time"

"ratelimitfiber/handlers"

"github.com/gofiber/fiber/v2"
)
Comment on lines +3 to +11
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Module import path looks wrong

The code imports ratelimitfiber/... while the directory is rate-limit-redis/.
Unless your go.mod module name is ratelimitfiber, this will not compile.
Please align the import prefixes with the module path.

🤖 Prompt for AI Agents
In rate-limit-redis/main.go lines 3 to 11, the import paths use the prefix
"ratelimitfiber" which does not match the module directory "rate-limit-redis".
Update all import paths to use the correct module prefix that matches the module
name defined in go.mod or the directory structure, ensuring the imports align
with the actual module path to fix compilation errors.


func main() {

app := fiber.New()
config.RedisInit()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Calling config.RedisInit() which initializes a global variable makes the main function dependent on global state. It would be better to have config.RedisInit return the client and pass it to the middleware explicitly.

Suggested change
config.RedisInit()
redisClient := config.RedisInit()

app.Use(middleware.RateLimiterMiddleware(config.RedisClient, time.Minute, 10))

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This line uses the global config.RedisClient. As suggested in config/redis.go, consider having RedisInit return the client and use that returned value here.

Suggested change
app.Use(middleware.RateLimiterMiddleware(config.RedisClient, time.Minute, 10))
app.Use(middleware.RateLimiterMiddleware(redisClient, time.Minute, 10))


Comment on lines +16 to +18
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Handle Redis init failures

After refactoring RedisInit to return an error, bubble it up and abort startup when Redis is down.

- config.RedisInit()
+ if err := config.RedisInit(); err != nil {
+     log.Fatalf("unable to start: %v", err)
+ }
🤖 Prompt for AI Agents
In rate-limit-redis/main.go around lines 16 to 18, the RedisInit function now
returns an error that is not being handled. Modify the code to capture the error
returned by RedisInit, check if it is non-nil, and if so, log the error and
abort the application startup to prevent running with a failed Redis connection.

app.Get("/home", handlers.Home)

app.Listen(":8080")
}
Comment on lines +21 to +22
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Check listen error

app.Listen returns an error that is currently ignored.

- app.Listen(":8080")
+ if err := app.Listen(":8080"); err != nil {
+     log.Fatalf("fiber failed: %v", err)
+ }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
app.Listen(":8080")
}
if err := app.Listen(":8080"); err != nil {
log.Fatalf("fiber failed: %v", err)
}
}
🤖 Prompt for AI Agents
In rate-limit-redis/main.go around lines 21 to 22, the call to
app.Listen(":8080") returns an error that is currently ignored. Modify the code
to capture the error returned by app.Listen and handle it appropriately, such as
logging the error and exiting the application if the listen operation fails.

36 changes: 36 additions & 0 deletions rate-limit-redis/middleware/limiter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package middleware

import (
"context"
"fmt"
"time"

"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
)

func RateLimiterMiddleware(client *redis.Client, ttl time.Duration, maxRequests int) fiber.Handler {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not use the limiter middleware with the storage adapater for redis ?
https://docs.gofiber.io/api/middleware/limiter
https://docs.gofiber.io/storage/redis/

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I intentionally built a custom Redis-based middleware to demonstrate how Fiber middleware can be extended or customized for specific use cases. That said, I will update the README that Fiber already provides a built-in rate limiting middleware with Redis support, which is ideal for most production scenarios.

return func(c *fiber.Ctx) error {
ctx := context.Background()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using context.Background() is not ideal in a middleware. Fiber's fiber.Ctx provides a context (c.Context()) that should be used for Redis operations. This context is tied to the request lifecycle and can carry request-scoped values or cancellation signals.

Suggested change
ctx := context.Background()
ctx := c.Context()

key := fmt.Sprintf("rate-limit:%s", c.IP())

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The Redis key format rate-limit:%s is hardcoded. Consider defining this format as a constant to improve maintainability and prevent typos.

Suggested change
key := fmt.Sprintf("rate-limit:%s", c.IP())
const rateLimitKeyFormat = "rate-limit:%s"
key := fmt.Sprintf(rateLimitKeyFormat, c.IP())


count, err := client.Incr(ctx, key).Result()
if err != nil {
return c.Status(500).JSON(fiber.Map{
"error": "Redis Error",
"details": err.Error(),
})
}
if count == 1 {
client.Expire(ctx, key, ttl)
}
Comment on lines +24 to +26
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Ignore-error write to Redis

If Expire fails the key will live forever, skewing limits.
Return a 500 or at least log the failure.

- if count == 1 {
-     client.Expire(ctx, key, ttl)
+ if count == 1 {
+     if err := client.Expire(ctx, key, ttl).Err(); err != nil {
+         return c.Status(500).JSON(fiber.Map{
+             "error":   "Redis Error",
+             "details": err.Error(),
+         })
+     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if count == 1 {
client.Expire(ctx, key, ttl)
}
if count == 1 {
if err := client.Expire(ctx, key, ttl).Err(); err != nil {
return c.Status(500).JSON(fiber.Map{
"error": "Redis Error",
"details": err.Error(),
})
}
}
🤖 Prompt for AI Agents
In rate-limit-redis/middleware/limiter.go around lines 24 to 26, the call to
client.Expire does not handle errors, which can cause keys to live indefinitely
and skew rate limits. Modify the code to check the error returned by
client.Expire, log the error if it occurs, and return a 500 error response or
propagate the error appropriately to prevent silent failures.

if count > int64(maxRequests) {
ttlVal, _ := client.TTL(ctx, key).Result()

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The error returned by client.TTL(ctx, key).Result() is ignored using the blank identifier (_). While TTL might return specific values for non-existent keys, it's generally safer to check for potential errors during Redis communication.

ttlVal, err := client.TTL(ctx, key).Result()
		if err != nil && err != redis.Nil {
			// Handle TTL error, maybe log it or return a 500
			// For this recipe, we might just log and continue with 0 retry-after
			fmt.Printf("Error getting TTL for key %s: %v\n", key, err)
			ttlVal = 0 // Default to 0 if TTL retrieval fails
		}

c.Set("Retry-After", fmt.Sprintf("%.0f", ttlVal.Seconds()))
return c.Status(429).JSON(fiber.Map{
"error": "Too Many Requests",
})
}
Comment on lines +27 to +33
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

TTL can be −1; guard before using

TTL returns -1 when no expiry is set and -2 when the key is gone.
Sending a negative Retry-After header violates the spec.

- ttlVal, _ := client.TTL(ctx, key).Result()
- c.Set("Retry-After", fmt.Sprintf("%.0f", ttlVal.Seconds()))
+ ttlVal, err := client.TTL(ctx, key).Result()
+ if err == nil && ttlVal > 0 {
+     c.Set("Retry-After", fmt.Sprintf("%.0f", ttlVal.Seconds()))
+ }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if count > int64(maxRequests) {
ttlVal, _ := client.TTL(ctx, key).Result()
c.Set("Retry-After", fmt.Sprintf("%.0f", ttlVal.Seconds()))
return c.Status(429).JSON(fiber.Map{
"error": "Too Many Requests",
})
}
if count > int64(maxRequests) {
ttlVal, err := client.TTL(ctx, key).Result()
if err == nil && ttlVal > 0 {
c.Set("Retry-After", fmt.Sprintf("%.0f", ttlVal.Seconds()))
}
return c.Status(429).JSON(fiber.Map{
"error": "Too Many Requests",
})
}
🤖 Prompt for AI Agents
In rate-limit-redis/middleware/limiter.go around lines 27 to 33, the TTL value
from Redis can be -1 or -2, indicating no expiry or missing key, which should
not be used directly in the Retry-After header. Add a check to ensure ttlVal is
positive before setting the Retry-After header; if ttlVal is negative, omit the
header or set a default positive value to comply with the HTTP spec.

return c.Next()
}
}