Rate Limiter, Retry, and Circuit Breaker: Their Crucial Roles in Modern SaaS Business

Rizki Nurhidayat
CodeX
Published in
6 min readJul 10, 2024

Introduction

Imagine you’re the captain of a spaceship navigating through the ever-expanding universe of Software as a Service (SaaS). Your mission: ensure your ship (service) runs smoothly, dodges asteroids (failures), and delivers cargo (transactions) efficiently. But in the vast space of digital services, challenges are plenty, and maintaining reliability is no small feat. This is where the unsung heroes — rate limiter, retry, and circuit breaker — come into play. These mechanisms are your trusty co-pilots, each playing a crucial role in ensuring your SaaS ship stays on course, no matter how turbulent the cosmic storms get. Let’s dive into how these heroes work and how you can implement them using Golang.

Rate Limiter

Theory and Concepts: A rate limiter is a mechanism that controls the number of requests a service can handle within a specific time period. Common rate limiting models include:

  1. Token Bucket: Uses a bucket filled with tokens. Each request requires one token. Tokens are replenished periodically. If the bucket is empty, the request is denied.
  2. Leaky Bucket: Similar to Token Bucket, but the bucket leaks at a fixed rate. Requests are added to the bucket and processed at a constant rate.
  3. Fixed Window: Limits the number of requests in fixed intervals, such as 100 requests per minute.
  4. Sliding Window: Limits the number of requests over a moving window, providing finer-grained control compared to Fixed Window.

Simple Implementation in Golang:

package main

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

type RateLimiter struct {
requests int
maxRequests int
resetInterval time.Duration
mutex sync.Mutex
}

func NewRateLimiter(maxRequests int, interval time.Duration) *RateLimiter {
rl := &RateLimiter{
maxRequests: maxRequests,
resetInterval: interval,
}
go rl.resetCount()
return rl
}

func (rl *RateLimiter) resetCount() {
for {
time.Sleep(rl.resetInterval)
rl.mutex.Lock()
rl.requests = 0
rl.mutex.Unlock()
}
}

func (rl *RateLimiter) Allow() bool {
rl.mutex.Lock()
defer rl.mutex.Unlock()
if rl.requests < rl.maxRequests {
rl.requests++
return true
}
return false
}

func rateLimitedHandler(w http.ResponseWriter, r *http.Request) {
if !rateLimiter.Allow() {
http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests)
return
}
fmt.Fprintln(w, "Request successful")
}

var rateLimiter = NewRateLimiter(5, time.Minute)

func main() {
http.HandleFunc("/", rateLimitedHandler)
http.ListenAndServe(":8080", nil)
}

Code Explanation:

  1. RateLimiter Struct: The RateLimiter struct tracks the number of ongoing requests (requests), the maximum allowed requests (maxRequests), and the interval for resetting the request count (resetInterval). A mutex (mutex) ensures thread-safe access to these variables.
  2. NewRateLimiter: This function creates a new RateLimiter instance and starts a goroutine to reset the request count at specified intervals.
  3. resetCount: This function runs in a goroutine to reset the request count to zero at specified intervals.
  4. Allow: This function checks if the current request count is less than the allowed maximum. If so, it increments the count and returns true; otherwise, it returns false.
  5. rateLimitedHandler: This HTTP handler uses the rate limiter to control request flow. If the request count exceeds the limit, it returns an HTTP 429 status (Too Many Requests). Otherwise, it returns a success message.

Retry

Theory and Concepts: Retry mechanisms attempt to re-send failed requests after a certain period. Common retry patterns include:

  1. Exponential Backoff: Each retry is delayed by an exponentially increasing amount of time.
  2. Fixed Interval: Each retry is delayed by a fixed interval of time.
  3. Jitter: Adds random variation to the delay time to avoid the thundering herd problem, where many retrying requests hit the server simultaneously.

Explanation of Thundering Herd Problem: The thundering herd problem occurs when many clients or requests try to access the same resource simultaneously after the same wait period. This can cause a sudden spike in server load, leading to failures or performance degradation. Adding jitter (random variation) to retry delays helps spread out the retry attempts, preventing this issue.

Simple Implementation in Golang:

package main

import (
"fmt"
"math/rand"
"time"
)

func retry(attempts int, sleep time.Duration, fn func() error) error {
var err error
for i := 0; i < attempts; i++ {
if err = fn(); err == nil {
return nil
}
time.Sleep(sleep)
sleep += time.Duration(rand.Int63n(int64(sleep)))
}
return err
}

func main() {
err := retry(5, time.Second, func() error {
// Simulate a request that might fail
if rand.Float32() < 0.7 {
return fmt.Errorf("failed request")
}
fmt.Println("Request successful")
return nil
})

if err != nil {
fmt.Println("All retries failed:", err)
}
}

Code Explanation:

  1. retry Function: The retry function takes the maximum number of attempts (attempts), the initial sleep duration (sleep), and the function to be retried (fn). If the function fails, it waits (sleep) and tries again until the maximum attempts are reached.
  2. sleep with Jitter: After each retry attempt, the sleep duration is increased by a random value to avoid the thundering herd problem.
  3. Simulating Requests: The main function simulates requests that may fail. If the request succeeds, it prints a success message. If all retries fail, it prints a failure message.

Circuit Breaker

Theory and Concepts: A circuit breaker is a mechanism that stops the flow of requests to a service that is continuously failing, allowing the system time to recover. There are three main states in this pattern:

  1. Closed: Requests flow as usual. If consecutive failures occur, the circuit breaker moves to the Open state.
  2. Open: All requests are automatically denied for a specified period. After the timeout, the circuit breaker moves to the Half-Open state.
  3. Half-Open: Limited requests are allowed to test the system. If successful, the circuit breaker returns to the Closed state. If failures continue, it reverts to the Open state.

Simple Implementation in Golang:

package main

import (
"fmt"
"math/rand"
"sync"
"time"
)

type CircuitBreaker struct {
failures int
threshold int
timeout time.Duration
state string
lastFailureTime time.Time
mutex sync.Mutex
}

func NewCircuitBreaker(threshold int, timeout time.Duration) *CircuitBreaker {
return &CircuitBreaker{
threshold: threshold,
timeout: timeout,
state: "Closed",
}
}

func (cb *CircuitBreaker) Allow() bool {
cb.mutex.Lock()
defer cb.mutex.Unlock()
if cb.state == "Open" && time.Since(cb.lastFailureTime) > cb.timeout {
cb.state = "Half-Open"
}
return cb.state != "Open"
}

func (cb *CircuitBreaker) Success() {
cb.mutex.Lock()
defer cb.mutex.Unlock()
cb.failures = 0
cb.state = "Closed"
}

func (cb *CircuitBreaker) Failure() {
cb.mutex.Lock()
defer cb.mutex.Unlock()
cb.failures++
if cb.failures >= cb.threshold {
cb.state = "Open"
cb.lastFailureTime = time.Now()
}
}

func main() {
cb := NewCircuitBreaker(3, 10*time.Second)

for i := 0; i < 10; i++ {
if !cb.Allow() {
fmt.Println("Request denied by circuit breaker")
time.Sleep(2 * time.Second)
continue
}

err := makeRequest()
if err != nil {
cb.Failure()
fmt.Println("Request failed")
} else {
cb.Success()
fmt.Println("Request successful")
}

time.Sleep(1 * time.Second)
}
}

func makeRequest() error {
// Simulate a request that might fail
if rand.Float32() < 0.7 {
return fmt.Errorf("failed request")
}
return nil
}

Code Explanation:

  1. CircuitBreaker Struct: The CircuitBreaker struct tracks the number of failures (failures), the failure threshold (threshold), the timeout duration (timeout), the current state (state), and the time of the last failure (lastFailureTime). A mutex (mutex) ensures thread-safe access.
  2. NewCircuitBreaker: This function creates a new CircuitBreaker instance with the specified failure threshold and timeout duration.
  3. Allow: This function checks if requests are allowed based on the circuit breaker’s state. If the state is Open and the timeout has elapsed, it transitions to Half-Open. Otherwise, it allows requests if the state is not Open.
  4. Success: This function resets the failure count and changes the state to Closed if a request is successful.
  5. Failure: This function increments the failure count. If the failure count reaches the threshold, the state changes to Open, and the time of the last failure is recorded.
  6. makeRequest: This function simulates requests that may fail. If the request succeeds, it returns nil. If it fails, it returns an error.

Conclusion

Rate limiter, retry, and circuit breaker are three essential components in the architecture of modern SaaS services. They play crucial roles in maintaining reliability, stability, and performance for services that charge based on transactions per unit time. Proper implementation of these mechanisms ensures better user experience and more efficient operations while protecting the system from overload and failures. The simple implementations in Golang provided here can serve as a foundation for understanding and applying these mechanisms in your SaaS applications.

--

--

Rizki Nurhidayat
CodeX
Writer for

🌟 Hitting life full throttle, no brakes. 💥 📈 Up for every challenge, down for every adventure. 🌍 💡 Dropping truths, stirring pots.🔥 👊 Embracing Mondays