Skip to main content

Echo WebSocket Rate Limiting

When developing real-time applications using WebSockets, resource management becomes crucial as your user base grows. Rate limiting is an essential technique that helps protect your server from being overwhelmed by too many connections or messages. In this guide, we'll explore how to implement rate limiting for Echo WebSocket connections.

Understanding WebSocket Rate Limiting

Rate limiting is a strategy used to control the amount of incoming and outgoing traffic to or from a network service. For WebSockets, this typically involves:

  1. Limiting connection attempts per client
  2. Controlling message frequency
  3. Managing message size
  4. Implementing reconnection backoff strategies

Without proper rate limiting, your WebSocket server could be vulnerable to denial of service attacks or resource exhaustion from legitimate but overly active clients.

Basic Rate Limiting Approaches

Let's explore several approaches to rate limit WebSocket connections in Echo:

1. Connection Rate Limiting

First, let's implement a middleware that limits how often a client can establish new WebSocket connections:

go
package main

import (
"sync"
"time"
"net/http"

"github.com/labstack/echo/v4"
"github.com/gorilla/websocket"
)

type IPRateLimiter struct {
ips map[string]time.Time
maxConns int
window time.Duration
mu sync.Mutex
}

func NewIPRateLimiter(maxConns int, window time.Duration) *IPRateLimiter {
return &IPRateLimiter{
ips: make(map[string]time.Time),
maxConns: maxConns,
window: window,
}
}

func (r *IPRateLimiter) Allow(ip string) bool {
r.mu.Lock()
defer r.mu.Unlock()

now := time.Now()

// Clean up old entries
for storedIP, timestamp := range r.ips {
if now.Sub(timestamp) > r.window {
delete(r.ips, storedIP)
}
}

// Count connections from this IP
count := 0
for storedIP := range r.ips {
if storedIP == ip {
count++
}
}

// Check if limit is reached
if count >= r.maxConns {
return false
}

// Store the new connection attempt
r.ips[ip+":"+now.String()] = now
return true
}

func main() {
e := echo.New()

// Create a rate limiter: max 5 connections per IP in a 1-minute window
rateLimiter := NewIPRateLimiter(5, time.Minute)

upgrader := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // In production, implement proper origin checking
},
}

e.GET("/ws", func(c echo.Context) error {
ip := c.RealIP()

if !rateLimiter.Allow(ip) {
return c.JSON(http.StatusTooManyRequests, map[string]string{
"error": "Rate limit exceeded. Try again later.",
})
}

ws, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
if err != nil {
return err
}
defer ws.Close()

// WebSocket handling logic here

return nil
})

e.Start(":8080")
}

This code implements a simple IP-based rate limiter that restricts each IP address to a maximum of 5 WebSocket connection attempts per minute.

2. Message Frequency Limiting

Once a connection is established, we also need to limit how quickly clients can send messages:

go
type Client struct {
conn *websocket.Conn
lastMsg time.Time
msgCount int
resetTime time.Time
}

func handleWebSocket(ws *websocket.Conn) {
client := &Client{
conn: ws,
resetTime: time.Now().Add(time.Minute),
}

for {
// Read message from WebSocket
_, message, err := ws.ReadMessage()
if err != nil {
break
}

now := time.Now()

// Reset counter after the window expires
if now.After(client.resetTime) {
client.msgCount = 0
client.resetTime = now.Add(time.Minute)
}

// Check if message rate is too high
client.msgCount++
if client.msgCount > 60 { // Max 60 messages per minute
ws.WriteMessage(websocket.TextMessage, []byte("Rate limit exceeded, slow down"))
continue
}

// Also check for burst prevention
if client.lastMsg.Add(50 * time.Millisecond).After(now) { // Minimum 50ms between messages
ws.WriteMessage(websocket.TextMessage, []byte("Sending too fast, slow down"))
continue
}

client.lastMsg = now

// Process the message...
processMessage(message)
}
}

This function implements two rate limiting strategies:

  • A maximum of 60 messages per minute
  • A minimum delay of 50ms between consecutive messages to prevent bursts

Practical Implementation: Token Bucket Algorithm

For more sophisticated rate limiting, the Token Bucket algorithm is commonly used. This approach provides more flexibility and control:

go
package main

import (
"sync"
"time"

"github.com/labstack/echo/v4"
"github.com/gorilla/websocket"
)

// TokenBucket implements a token bucket rate limiter
type TokenBucket struct {
capacity int // Maximum number of tokens
tokens float64 // Current token count
rate float64 // Tokens per second refill rate
lastCheck time.Time
mutex sync.Mutex
}

// NewTokenBucket creates a new token bucket rate limiter
func NewTokenBucket(capacity int, refillRate float64) *TokenBucket {
return &TokenBucket{
capacity: capacity,
tokens: float64(capacity),
rate: refillRate,
lastCheck: time.Now(),
}
}

// Allow checks if an action should be allowed and consumes a token if it is
func (tb *TokenBucket) Allow() bool {
tb.mutex.Lock()
defer tb.mutex.Unlock()

now := time.Now()
elapsed := now.Sub(tb.lastCheck).Seconds()
tb.lastCheck = now

// Refill tokens based on elapsed time
tb.tokens += elapsed * tb.rate

// Cap tokens at capacity
if tb.tokens > float64(tb.capacity) {
tb.tokens = float64(tb.capacity)
}

// Check if we can consume a token
if tb.tokens < 1 {
return false
}

// Consume a token
tb.tokens--
return true
}

Now we can use this token bucket in our WebSocket handler:

go
func websocketHandler(c echo.Context) error {
upgrader := websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}

conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
if err != nil {
return err
}
defer conn.Close()

// Create rate limiter: 10 message capacity with 2 messages/second refill rate
limiter := NewTokenBucket(10, 2)

for {
// Check if message should be allowed before reading
if !limiter.Allow() {
conn.WriteMessage(websocket.TextMessage, []byte("Rate limit exceeded, please wait"))
time.Sleep(500 * time.Millisecond)
continue
}

// Read message
messageType, p, err := conn.ReadMessage()
if err != nil {
return err
}

// Process and echo back the message
conn.WriteMessage(messageType, p)
}
}

Real-World Example: Chat Application

Let's implement a simple chat application with rate limiting to demonstrate a practical use case:

go
package main

import (
"log"
"net/http"
"sync"
"time"

"github.com/labstack/echo/v4"
"github.com/gorilla/websocket"
)

type Client struct {
conn *websocket.Conn
messageBucket *TokenBucket
connBucket *TokenBucket
}

type ChatServer struct {
clients map[*Client]bool
broadcast chan []byte
register chan *Client
unregister chan *Client
connLimiter *IPRateLimiter
mu sync.Mutex
}

func NewChatServer() *ChatServer {
return &ChatServer{
clients: make(map[*Client]bool),
broadcast: make(chan []byte),
register: make(chan *Client),
unregister: make(chan *Client),
connLimiter: NewIPRateLimiter(5, time.Minute),
}
}

func (server *ChatServer) Run() {
for {
select {
case client := <-server.register:
server.mu.Lock()
server.clients[client] = true
server.mu.Unlock()
case client := <-server.unregister:
server.mu.Lock()
if _, ok := server.clients[client]; ok {
delete(server.clients, client)
client.conn.Close()
}
server.mu.Unlock()
case message := <-server.broadcast:
server.mu.Lock()
for client := range server.clients {
err := client.conn.WriteMessage(websocket.TextMessage, message)
if err != nil {
client.conn.Close()
delete(server.clients, client)
}
}
server.mu.Unlock()
}
}
}

func (server *ChatServer) ServeWebSocket(c echo.Context) error {
ip := c.RealIP()

// Check connection rate limit
if !server.connLimiter.Allow(ip) {
return c.JSON(http.StatusTooManyRequests, map[string]string{
"error": "Too many connection attempts. Please try again later.",
})
}

upgrader := websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // Adjust for production
},
}

conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil)
if err != nil {
log.Println(err)
return err
}

client := &Client{
conn: conn,
messageBucket: NewTokenBucket(10, 2), // 10 messages, 2/s refill rate
connBucket: NewTokenBucket(5, 0.1), // 5 reconnects, 0.1/s refill rate
}

server.register <- client

// Read messages from the client with rate limiting
go func() {
defer func() {
server.unregister <- client
}()

for {
if !client.messageBucket.Allow() {
conn.WriteMessage(websocket.TextMessage, []byte("Message rate limit exceeded. Please slow down."))
time.Sleep(500 * time.Millisecond)
continue
}

_, message, err := conn.ReadMessage()
if err != nil {
break
}

server.broadcast <- message
}
}()

return nil
}

func main() {
e := echo.New()
chatServer := NewChatServer()

go chatServer.Run()

e.GET("/ws", chatServer.ServeWebSocket)

// Serve static files for the chat client
e.Static("/", "public")

e.Logger.Fatal(e.Start(":8080"))
}

This example showcases a chat server that implements:

  1. Connection rate limiting (max 5 connections per minute per IP)
  2. Message rate limiting using token buckets (max 10 messages with 2/second refill rate)
  3. Reconnection limits (5 reconnects with slow 0.1/second refill rate)

Advanced Rate Limiting Considerations

For production systems, consider these additional rate limiting strategies:

Global vs. Per-User Rate Limiting

go
type RateLimitManager struct {
globalLimiter *TokenBucket
userLimiters map[string]*TokenBucket
mu sync.Mutex
}

func NewRateLimitManager(globalRate, userRate float64) *RateLimitManager {
return &RateLimitManager{
globalLimiter: NewTokenBucket(1000, globalRate),
userLimiters: make(map[string]*TokenBucket),
}
}

func (m *RateLimitManager) Allow(userID string) bool {
m.mu.Lock()
defer m.mu.Unlock()

// Check global limit first
if !m.globalLimiter.Allow() {
return false
}

// Get or create user limiter
limiter, exists := m.userLimiters[userID]
if !exists {
limiter = NewTokenBucket(100, 5) // 100 tokens, 5/second refill
m.userLimiters[userID] = limiter
}

return limiter.Allow()
}

Adaptive Rate Limiting

go
type AdaptiveRateLimiter struct {
bucket *TokenBucket
serverLoad float64
maxLoad float64
adjustFactor float64
mu sync.Mutex
}

func (a *AdaptiveRateLimiter) UpdateLoad(load float64) {
a.mu.Lock()
defer a.mu.Unlock()

a.serverLoad = load

// Adjust token refill rate based on server load
loadRatio := a.serverLoad / a.maxLoad
if loadRatio > 0.8 {
// Reduce token refill rate when server is under heavy load
a.bucket.rate = a.bucket.rate * (1 - a.adjustFactor)
} else if loadRatio < 0.2 {
// Increase token refill rate when server load is light
a.bucket.rate = a.bucket.rate * (1 + a.adjustFactor)
}
}

Client-Side Considerations

It's also essential to inform clients about rate limits and provide guidance on how to handle them properly. Here's a simple client-side implementation:

javascript
// Client-side JavaScript for WebSocket with exponential backoff
class RateLimitedWebSocket {
constructor(url) {
this.url = url;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.baseReconnectDelay = 1000; // 1 second
this.connect();
}

connect() {
this.socket = new WebSocket(this.url);

this.socket.onopen = () => {
console.log('Connected to WebSocket server');
this.reconnectAttempts = 0; // Reset reconnect attempts on successful connection
};

this.socket.onmessage = (event) => {
const message = event.data;

// Handle rate limit messages specially
if (message.includes('Rate limit exceeded')) {
console.warn('Rate limit warning:', message);
// You could implement client-side throttling here
} else {
// Handle normal messages
console.log('Received:', message);
}
};

this.socket.onclose = () => {
if (this.reconnectAttempts < this.maxReconnectAttempts) {
const delay = this.calculateReconnectDelay();
console.log(`Connection closed. Reconnecting in ${delay}ms...`);
setTimeout(() => this.connect(), delay);
this.reconnectAttempts++;
} else {
console.error('Max reconnection attempts reached.');
}
};

this.socket.onerror = (error) => {
console.error('WebSocket error:', error);
};
}

calculateReconnectDelay() {
// Exponential backoff with jitter
const exponentialDelay = this.baseReconnectDelay * Math.pow(2, this.reconnectAttempts);
const jitter = Math.random() * 0.5 * exponentialDelay;
return Math.min(exponentialDelay + jitter, 30000); // Cap at 30 seconds
}

send(message) {
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(message);
} else {
console.warn('Cannot send message, socket is not open');
}
}
}

// Usage
const ws = new RateLimitedWebSocket('ws://localhost:8080/ws');

// Send button click handler
document.getElementById('sendButton').addEventListener('click', () => {
const message = document.getElementById('messageInput').value;
ws.send(message);
});

Summary

WebSocket rate limiting is an essential practice for building robust real-time applications. In this guide, we've covered:

  1. The importance of rate limiting for WebSocket connections
  2. Different approaches to rate limiting:
    • Connection rate limiting
    • Message frequency limiting
    • Token bucket algorithm implementation
  3. A practical chat application example with comprehensive rate limiting
  4. Advanced considerations including:
    • Global vs. per-user rate limiting
    • Adaptive rate limiting
  5. Client-side handling of rate limits with exponential backoff

By implementing proper rate limiting strategies, you can protect your server resources, ensure fair usage among clients, and create a more reliable real-time application.

Additional Resources and Exercises

Further Reading

Exercises

  1. Basic: Modify the token bucket implementation to handle different priority levels for messages.

  2. Intermediate: Implement a distributed rate limiter using Redis to coordinate rate limits across multiple server instances.

  3. Advanced: Create a dashboard that visualizes the current rate limits, connection counts, and rejected messages in real-time.

  4. Challenge: Design and implement a system that can automatically adjust rate limits based on observed client behavior, rewarding well-behaved clients with higher limits while restricting abusive ones.

By understanding and implementing proper WebSocket rate limiting, you'll be able to build more robust and scalable real-time applications with Echo.



If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)