Overview

3 AM, phone buzzing wildly. Open it — the alert group is already on fire. Payment service P99 latency jumped from 50ms to 12 seconds, error rate past 80%. Worse, the failure cascaded like dominoes: user service timeout → auth failures → inventory service retry storm → message queue backed up → payment callbacks all timed out. The monitoring dashboard went entirely red in thirty seconds.

This isn’t a unique disaster. The finer you split microservices, the longer the call chain, and the easier it is for one local failure to cascade into a global avalanche. I once managed 50+ microservices at a new energy logistics platform and lived through three similar incidents. The last one was the worst — a downstream search service’s database connection pool got exhausted, causing all upstream services depending on search results to have their thread pools cascade-depleted. The entire transaction pipeline was down for 17 minutes.

After that incident, we spent two weeks rebuilding our rate limiting, circuit breaking, and degradation system. We brought P99 down from 200ms to 170ms, and more importantly, we never had a cascading failure again. This article covers every pitfall, architecture decision, and production-proven configuration I’ve encountered.

Let’s clarify the relationship between the three — people use them interchangeably, but they solve different problems:

MechanismWhat It SolvesAnalogyPosition
Rate LimitingTraffic exceeds system capacityA supermarket opens only 3 checkout lanes; once full, no more customers enterEntry point
Circuit BreakingDownstream service failure; prevent propagationA fuse that trips when current is too highMiddle of call chain
DegradationProvide alternatives when core features unavailablePower goes out, emergency lights kick in — dimmer but you can still seeBusiness logic layer

All three work together: rate limiting controls input at the front, circuit breaking cuts fault propagation in the middle, and degradation ensures user experience doesn’t completely collapse at the tail end.

Avalanche Effect: Why Microservices Fail Harder Than Monoliths

In a monolith, method calls are in-process function calls — microsecond-level. Split into microservices, every call becomes a network request — networks can jitter, DNS can be slow, TCP handshakes have overhead, serialization/deserialization takes time. A single user request passing through 5-7 service layers from the gateway is the norm.

Fault Propagation Path

Look at a real call chain:

User Request → API Gateway → Order Service → User Service (Auth)
                                  → Inventory Service (Deduct)
                                  → Coupon Service
                                  → Payment Service → Third-party Payment Gateway

Suppose the third-party payment gateway slows down (response time goes from 100ms to 5 seconds). What happens?

  1. Payment service threads are occupied, waiting for the third party to respond
  2. Payment service thread pool gradually exhausts, new requests queue up
  3. Order service’s call to payment service times out, starts retrying
  4. Retried requests pile onto the already-overloaded payment service, making things worse
  5. Order service’s own thread pool gets dragged down
  6. Inventory service, coupon service all affected
  7. API Gateway thread pool exhausted, entire system refuses service

Key data: if the order service has 200 worker threads, each request calls the payment service, and payment service slows to 5 seconds per response — then 200 threads get occupied within 5 seconds. All subsequent new requests either queue or get rejected. The order service has no code bug, but it’s been killed by its downstream.

Three Typical Failure Modes

Failure ModeTriggerSymptomRisk Level
Thread pool exhaustionSlow downstream + no timeoutService completely unresponsiveHigh
Connection pool exhaustionDownstream doesn’t release connectionsNew requests can’t establish connectionsHigh
Retry stormAuto-retry after timeoutTraffic amplified N timesCritical
Cascading avalancheMulti-layer dependency without isolationFull-chain paralysisCritical

Retry storms are the most easily overlooked. Suppose A calls B, timeout 3 seconds, retry 3 times. When B slows down, each original request from A becomes 3 requests hitting B. If B also calls C and retries 3 times — one request from C amplifies to 9 requests hitting B. The traffic amplification effect is terrifying. This is why I strongly recommend: retries and circuit breaking must work together — don’t retry when the circuit is open.

Rate Limiting: Caging the Traffic

Rate Limiting Algorithm Selection

Four mainstream algorithms, each with its use case:

AlgorithmPrincipleProsConsUse Case
Fixed window counterCount within fixed windowSimpleBoundary spikeLow precision
Sliding windowSmooth counting across sub-windowsHigh precisionSlightly complexGeneral purpose
Leaky bucketProcess requests at constant rateAbsolutely smooth flowCan’t handle burstsProtecting weak downstream
Token bucketIssue tokens at fixed rateAllows burst trafficNeeds tuningAPI gateway entry

The difference between leaky bucket and token bucket is most commonly confused. Think of it this way:

  • Leaky bucket: A faucet keeps filling water, the bucket has a small hole at the bottom that leaks at a constant rate. If water comes in too fast, it overflows and gets discarded. No matter how urgent you are, the outflow rate never changes.
  • Token bucket: The system puts tokens into the bucket at a fixed rate. Requests need to grab a token first; if none available, they wait. The bucket can accumulate tokens, so short burst traffic can be handled.

My recommendation: use token bucket at API gateway entry (allows bursts), leaky bucket for protecting weak downstream (absolutely smooth). In practice, token bucket is sufficient for most scenarios; leaky bucket is for cases where no bursts are allowed at all (like writing to a database).

Go Token Bucket Implementation

Here’s a production-ready Go token bucket implementation. I use it on my ops API gateway, handling millions of requests daily without issues:

package ratelimit

import (
	"context"
	"sync"
	"time"
)

// TokenBucket token bucket rate limiter
type TokenBucket struct {
	mu         sync.Mutex
	rate       float64   // token refill rate (tokens/second)
	burst      float64   // bucket capacity (max tokens)
	tokens     float64   // current token count
	lastUpdate time.Time // last token refill time
}

// NewTokenBucket creates a rate limiter
// rate: tokens per second
// burst: max tokens in bucket (allowed burst)
func NewTokenBucket(rate, burst float64) *TokenBucket {
	return &TokenBucket{
		rate:       rate,
		burst:      burst,
		tokens:     burst, // start with full bucket
		lastUpdate: time.Now(),
	}
}

// Allow tries to take 1 token, non-blocking
func (tb *TokenBucket) Allow() bool {
	return tb.AllowN(1)
}

// AllowN tries to take n tokens, non-blocking
func (tb *TokenBucket) AllowN(n float64) bool {
	tb.mu.Lock()
	defer tb.mu.Unlock()

	now := time.Now()
	// Calculate elapsed time and refill tokens
	elapsed := now.Sub(tb.lastUpdate).Seconds()
	tb.tokens += elapsed * tb.rate
	// Tokens can't exceed bucket capacity
	if tb.tokens > tb.burst {
		tb.tokens = tb.burst
	}
	tb.lastUpdate = now

	// Check if enough tokens
	if tb.tokens >= n {
		tb.tokens -= n
		return true
	}
	return false
}

// Wait blocks until a token is available or context expires
func (tb *TokenBucket) Wait(ctx context.Context) error {
	for {
		if tb.Allow() {
			return nil
		}
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(10 * time.Millisecond):
			// Retry every 10ms, avoid busy-waiting
		}
	}
}

Key points of this implementation:

  1. Lazy token refill: Instead of running a goroutine to periodically add tokens, it calculates the refill based on elapsed time on each request. This avoids extra goroutine overhead and is more stable under high concurrency.
  2. Initial full bucket: When the service starts, the bucket is full, handling startup burst traffic.
  3. Mutex protection: sync.Mutex ensures thread safety. For higher performance, you could use atomic operations, but code complexity increases.

Distributed Rate Limiting: When Single-Node Isn’t Enough

Single-node rate limiting only protects individual instances. If your service has 10 replicas, each limited to 1000 QPS, theoretically the whole service can handle 10000 QPS. But if the gateway doesn’t do global rate limiting, 10000 QPS might overwhelm one instance due to uneven distribution.

The core idea of distributed rate limiting: put the counter in Redis, all instances share one counter.

package ratelimit

import (
	"context"
	"fmt"
	"time"

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

// RedisRateLimiter distributed rate limiter based on Redis
// Uses sliding window algorithm
type RedisRateLimiter struct {
	client *redis.Client
	key    string        // rate limit key, e.g. "rate_limit:order_service"
	rate   int           // max requests within window
	window time.Duration // window size
}

func NewRedisRateLimiter(client *redis.Client, key string, rate int, window time.Duration) *RedisRateLimiter {
	return &RedisRateLimiter{
		client: client,
		key:    key,
		rate:   rate,
		window: window,
	}
}

// Allow uses Redis Lua script for atomicity
func (r *RedisRateLimiter) Allow(ctx context.Context) (bool, error) {
	// Lua script: atomically remove old records + count + decide
	script := `
		local key = KEYS[1]
		local now = tonumber(ARGV[1])
		local window = tonumber(ARGV[2])
		local rate = tonumber(ARGV[3])

		-- Remove records outside the window
		redis.call('ZREMRANGEBYSCORE', key, 0, now - window)

		-- Get current count within window
		local count = redis.call('ZCARD', key)

		if count < rate then
			-- Under limit, record this request
			redis.call('ZADD', key, now, now .. ':' .. math.random())
			redis.call('PEXPIRE', key, window)
			return 1
		else
			return 0
		end
	`

	now := time.Now().UnixMilli()
	result, err := r.client.Eval(ctx, script, []string{r.key},
		now, r.window.Milliseconds(), r.rate).Int()
	if err != nil {
		return false, fmt.Errorf("redis eval failed: %w", err)
	}
	return result == 1, nil
}

Production considerations:

  1. Fallback when Redis is unavailable: If Redis goes down, should rate limiting pass through or reject? I recommend pass-through + degrade to single-node limiting. Rate limiting exists to protect the system, but if the Redis it depends on goes down and all requests get rejected, that’s worse than no rate limiting at all.
  2. Lua script is mandatory: Without it, the two-step ZCARD + ZADD has race conditions. Lua scripts execute atomically in Redis.
  3. Sorted Set memory overhead: Each request stores a record in the ZSet. At high QPS, memory grows fast. If QPS exceeds 100K, consider switching to Redis INCR + EXPIRE — slightly less precise but much smaller memory footprint.

Sentinel Rate Limiting in Practice

Alibaba’s Sentinel is the most popular traffic control component in the Java ecosystem. Compared to hand-written rate limiters, Sentinel’s advantage is its built-in dashboard, hot parameter limiting, and dynamic rule adjustment.

// Sentinel rate limiting rule configuration
FlowRule rule = new FlowRule();
rule.setResource("orderService.createOrder");
rule.setGrade(RuleConstant.FLOW_GRADE_QPS);  // QPS mode
rule.setCount(1000);                           // max 1000 per second
rule.setLimitApp("default");                    // applies to all sources

// Hot parameter limiting: per-user-ID limiting, max 10/s per user
ParamFlowRule paramRule = new ParamFlowRule("orderService.createOrder")
    .setParamIdx(0)        // use first parameter as limiting dimension
    .setGrade(RuleConstant.FLOW_GRADE_QPS)
    .setCount(10);          // per-user QPS limit
// VIP users get 100 QPS
paramRule.setParamItem(new ParamFlowItem()
    .setObject("user_vip_123")
    .setClassType(String.class.getName())
    .setCount(100));

// Load rules
FlowRuleManager.loadRules(Collections.singletonList(rule));
ParamFlowRuleManager.loadRules(Collections.singletonList(paramRule));

// Usage in business code
@SentinelResource(value = "orderService.createOrder",
    blockHandler = "createOrderBlockHandler")
public Order createOrder(String userId, OrderRequest req) {
    // normal business logic
    return orderRepository.save(req);
}

// Fallback handler when rate limited
public Order createOrderBlockHandler(String userId, OrderRequest req,
    BlockException ex) {
    // Return default order or throw friendly message
    throw new ServiceException("System busy, please retry later", 429);
}

Hot parameter limiting is Sentinel’s killer feature. Regular limiting is per-resource (“createOrder API allows 1000/s”), while hot parameter limiting is per-user (“User A max 10/s, VIP users up to 100/s”). This is especially useful in multi-tenant SaaS.

Circuit Breaking: Installing a Fuse for Failures

Circuit Breaker State Machine

A circuit breaker has three states, forming a finite state machine:

              failure rate exceeds threshold
  ┌─────────┐ ──────────────→ ┌─────────┐
  │ CLOSED  │                │  OPEN   │
  │ (normal)│ ←───────────── │ (tripped)│
  └─────────┘  probe success  └─────────┘
       ↑                        │
       │                        │ wait for cooldown
       │                        ↓
       │                   ┌──────────┐
       └────────────────── │HALF_OPEN │
           probe success     │ (probing)│
                           └──────────┘
                              │ probe failure
                           ┌─────────┐
                           │  OPEN   │
                           └─────────┘

State meanings:

  • CLOSED: Normal state, requests pass through. The breaker tracks success rate and slow call ratio in the background.
  • OPEN: Tripped state, all requests are immediately rejected without calling the downstream. Degradation logic executes. After the cooldown period, it transitions to half-open.
  • HALF_OPEN: Probing state, a small number of requests are allowed through to check if the downstream has recovered. Success → CLOSED; failure → OPEN.

Resilience4j Circuit Breaker Configuration

Resilience4j is Hystrix’s successor (Hystrix is no longer maintained). It’s modular — you only include the modules you need:

<!-- pom.xml: only include needed modules -->
<dependencies>
    <dependency>
        <groupId>io.github.resilience4j</groupId>
        <artifactId>resilience4j-circuitbreaker</artifactId>
        <version>2.2.0</version>
    </dependency>
    <dependency>
        <groupId>io.github.resilience4j</groupId>
        <artifactId>resilience4j-ratelimiter</artifactId>
        <version>2.2.0</version>
    </dependency>
    <dependency>
        <groupId>io.github.resilience4j</groupId>
        <artifactId>resilience4j-bulkhead</artifactId>
        <version>2.2.0</version>
    </dependency>
</dependencies>

Production-grade circuit breaker configuration:

# application.yml
resilience4j:
  circuitbreaker:
    configs:
      default:
        # Sliding window configuration
        slidingWindowType: COUNT_BASED       # count-based statistics
        slidingWindowSize: 100               # last 100 requests
        minimumNumberOfCalls: 20            # need at least 20 calls before evaluating
        # Failure thresholds
        failureRateThreshold: 50             # trip at >= 50% failure rate
        slowCallRateThreshold: 60            # trip at >= 60% slow call ratio
        slowCallDurationThreshold: 2s        # calls over 2s count as slow
        # State transitions
        waitDurationInOpenState: 30s         # wait 30s after tripping before half-open
        permittedNumberOfCallsInHalfOpenState: 10  # allow 10 probe calls in half-open
        # Which exceptions count as failures
        recordExceptions:
          - java.io.IOException
          - java.util.concurrent.TimeoutException
          - feign.FeignException.GatewayTimeout
        ignoreExceptions:
          - com.example.BusinessException    # business exceptions don't count
    instances:
      paymentService:
        baseConfig: default
        # Payment service: lower thresholds — payment chain is more sensitive
        failureRateThreshold: 30
        slowCallDurationThreshold: 1s
      userService:
        baseConfig: default
        # User service: allow higher failure rate since degradation is possible
        failureRateThreshold: 60

How to tune these parameters? Here’s a set of empirical values based on managing 50+ microservices:

ParameterRecommended (Core)Recommended (Non-core)Rationale
slidingWindowSize10060Too small: insufficient samples; too large: slow reaction
failureRateThreshold30-50%50-70%Core services need more sensitivity
slowCallDurationThreshold1-2s3-5s2-3x downstream P99
waitDurationInOpenState30-60s15-30sWait for downstream recovery
half-open probes105Not too many

Pitfall: minimumNumberOfCalls is often overlooked. If set to 0, right after startup with only one request that fails, the failure rate is 100% — immediately tripping the breaker. Setting it to 20 means at least 20 calls are needed before evaluation, preventing small-sample false positives.

Go Circuit Breaker Implementation

If your services are in Go (like our entire ops platform), you can use sony/gobreaker or implement your own. Here’s a lean but usable implementation:

package circuitbreaker

import (
	"errors"
	"sync"
	"time"
)

// State circuit breaker state
type State int

const (
	StateClosed   State = iota // normal
	StateOpen                  // tripped
	StateHalfOpen              // probing
)

// Settings circuit breaker configuration
type Settings struct {
	Name          string
	MaxRequests   uint32        // probe requests allowed in half-open
	Interval      time.Duration // stats window in closed state
	Timeout       time.Duration // cooldown in open state
	ReadyToTrip   func(counts Counts) bool // trip condition
	OnStateChange func(name string, from, to State)
}

// Counts request statistics
type Counts struct {
	Requests            uint32
	TotalSuccesses      uint32
	TotalFailures       uint32
	ConsecutiveFailures uint32
}

// CircuitBreaker circuit breaker
type CircuitBreaker struct {
	mu          sync.Mutex
	settings    Settings
	state       State
	generation  uint64
	expiry      time.Time
	counts      Counts
}

// NewCircuitBreaker creates a circuit breaker
func NewCircuitBreaker(s Settings) *CircuitBreaker {
	if s.ReadyToTrip == nil {
		// Default: trip after 5 consecutive failures
		s.ReadyToTrip = func(c Counts) bool {
			return c.ConsecutiveFailures >= 5
		}
	}
	cb := &CircuitBreaker{settings: s}
	cb.toNewGeneration(time.Now())
	return cb
}

// Execute runs a request with circuit breaking logic
func (cb *CircuitBreaker) Execute(req func() error) error {
	generation, err := cb.beforeRequest()
	if err != nil {
		return err
	}

	err = req()

	cb.afterRequest(generation, err)
	return err
}

func (cb *CircuitBreaker) beforeRequest() (uint64, error) {
	cb.mu.Lock()
	defer cb.mu.Unlock()

	now := time.Now()
	state, generation := cb.currentState(now)

	if state == StateOpen {
		// Tripped — reject without executing
		return generation, ErrCircuitOpen
	}
	return generation, nil
}

func (cb *CircuitBreaker) afterRequest(before uint64, err error) {
	cb.mu.Lock()
	defer cb.mu.Unlock()

	now := time.Now()
	state, generation := cb.currentState(now)
	if before != generation {
		// State changed, discard this count
		return
	}

	cb.counts.Requests++
	if err != nil {
		cb.counts.TotalFailures++
		cb.counts.ConsecutiveFailures++
	} else {
		cb.counts.TotalSuccesses++
		cb.counts.ConsecutiveFailures = 0
	}

	// Check if state should change
	if cb.state == StateClosed && cb.settings.ReadyToTrip(cb.counts) {
		cb.setState(StateOpen, now)
	} else if cb.state == StateHalfOpen && cb.counts.Requests >= cb.settings.MaxRequests {
		// Probes exhausted — decide based on results
		if cb.counts.TotalSuccesses > 0 {
			cb.setState(StateClosed, now)
		} else {
			cb.setState(StateOpen, now)
		}
	}
}

var ErrCircuitOpen = errors.New("circuit breaker is open")

func (cb *CircuitBreaker) currentState(now time.Time) (State, uint64) {
	switch cb.state {
	case StateClosed:
		if !cb.expiry.IsZero() && now.After(cb.expiry) {
			cb.toNewGeneration(now)
		}
	case StateOpen:
		if now.After(cb.expiry) {
			// Cooldown over — enter half-open
			cb.setState(StateHalfOpen, now)
		}
	}
	return cb.state, cb.generation
}

func (cb *CircuitBreaker) setState(state State, now time.Time) {
	if cb.state == state {
		return
	}
	prev := cb.state
	cb.state = state
	cb.toNewGeneration(now)
	if cb.settings.OnStateChange != nil {
		cb.settings.OnStateChange(cb.settings.Name, prev, state)
	}
}

func (cb *CircuitBreaker) toNewGeneration(now time.Time) {
	cb.generation++
	cb.counts = Counts{}

	switch cb.state {
	case StateClosed:
		if cb.settings.Interval == 0 {
			cb.expiry = time.Time{} // never expires
		} else {
			cb.expiry = now.Add(cb.settings.Interval)
		}
	case StateOpen:
		cb.expiry = now.Add(cb.settings.Timeout)
	case StateHalfOpen:
		cb.expiry = time.Time{} // never expires, waits for probe completion
	}
}

Usage:

cb := circuitbreaker.NewCircuitBreaker(circuitbreaker.Settings{
    Name:        "payment-service",
    MaxRequests: 5,                    // 5 probes in half-open
    Interval:    60 * time.Second,     // 60s stats window in closed
    Timeout:     30 * time.Second,     // 30s cooldown in open
    ReadyToTrip: func(c circuitbreaker.Counts) bool {
        // Trip at >50% failure rate with at least 10 requests
        if c.Requests < 10 {
            return false
        }
        failureRate := float64(c.TotalFailures) / float64(c.Requests)
        return failureRate >= 0.5
    },
    OnStateChange: func(name string, from, to circuitbreaker.State) {
        log.Printf("[CircuitBreaker] %s: %v → %v", name, from, to)
        // Push alert on state change
        alert.Send(fmt.Sprintf("Circuit breaker %s: %v → %v", name, from, to))
    },
})

// Usage in business code
err := cb.Execute(func() error {
    return paymentClient.Charge(ctx, order)
})
if errors.Is(err, circuitbreaker.ErrCircuitOpen) {
    // Circuit open — execute degradation
    return fallbackPayment(ctx, order)
}

Envoy Proxy-Layer Circuit Breaking

If you’re using Service Mesh (like Istio), circuit breaking can be configured at the mesh layer via Envoy — no code changes needed. This is my recommended approach: governance logic sinks to the infrastructure layer, keeping business code clean.

# Istio DestinationRule for Envoy circuit breaking
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: payment-service-cb
spec:
  host: payment-service.default.svc.cluster.local
  trafficPolicy:
    outlierDetection:
      # Eject after 5 consecutive 5xx errors
      consecutive5xxErrors: 5
      # Eject after 3 consecutive gateway errors (502/503/504)
      consecutiveGatewayErrors: 3
      # Detection interval
      interval: 30s
      # Base ejection duration
      baseEjectionTime: 30s
      # Max ejection ratio: at most 50% of instances
      maxEjectionPercent: 50
      # Min healthy ratio: keep at least 50% available
      minHealthPercent: 50
    connectionPool:
      tcp:
        maxConnections: 100        # max connections
        connectTimeout: 2s         # connection timeout
      http:
        http1MaxPendingRequests: 50    # max pending requests
        http2MaxRequests: 200          # max concurrent requests
        maxRequestsPerConnection: 10   # max requests per connection
        maxRetries: 2                  # max retries

What’s the difference between Envoy and Resilience4j circuit breaking?

DimensionEnvoy (Service Mesh)Resilience4j (SDK)
IntrusivenessZero — config onlyRequires code annotations
GranularityInstance-level (evict unhealthy)Request-level (per resource)
Language-agnosticYesJava only
Dynamic adjustmentYes (K8s CRD hot update)Yes but needs config center
Use caseMulti-language microservicesSingle-language Java ecosystem

My recommendation: If microservices span 3+ languages, use Service Mesh layer circuit breaking. If all Java, Resilience4j is more flexible. If all Go, use gobreaker or implement your own. (See Related: Istio Service Mesh Getting Started)

Degradation: Making the System Fail Gracefully

Degradation is the last line of defense. Rate limiting and circuit breaking both “reject requests” — degradation “provides alternatives.” A good degradation strategy makes failures nearly invisible to users.

Degradation Strategy Classification

TypeTriggerHandlingExample
Return default valueDownstream unavailableReturn preset default responseRecommendation list down, return trending items
Return cachedData source unavailableReturn stale dataProduct details from cache, tolerate staleness
Simplify flowCore path affectedSkip non-core stepsSkip coupon validation during checkout
Async-ifySync call timeoutConvert to async processingPayment becomes async, return “processing”
Feature offNon-core feature failureDisable feature entirelyComments service down, hide comment section

Design Principles

  1. Separate core and non-core paths. Checkout and payment are core; recommendations and comments are non-core. Non-core can be degraded anytime; core paths must be protected.
  2. Degradation must be pre-tested. Don’t wait for a failure to discover your degradation code has bugs. Chaos engineering exercises must include degradation validation.
  3. Degradation needs switches. Toggle dynamically through config center — don’t hardcode. One command to degrade, one command to recover.
  4. Degradation is not an error. The return should be a “usable second-best result,” not a 500 error code.

Degradation Implementation Example

Degradation chain in Go:

// PaymentService with degradation chain
type PaymentService struct {
	primaryPayment  PaymentClient   // primary payment channel
	fallbackPayment PaymentClient   // fallback payment channel
	cache           CacheClient     // cache
	cb              *circuitbreaker.CircuitBreaker // circuit breaker
}

// Charge with three-tier degradation strategy
func (s *PaymentService) Charge(ctx context.Context, order *Order) (*PaymentResult, error) {
	// Strategy 1: Primary channel normal → normal payment
	result, err := s.cb.Execute(func() error {
		result, err := s.primaryPayment.Charge(ctx, order)
		if err == nil {
			order.Result = result
		}
		return err
	})

	if err == nil {
		return result, nil
	}

	// Strategy 2: Primary circuit open → switch to fallback
	if errors.Is(err, circuitbreaker.ErrCircuitOpen) {
		log.Printf("primary payment circuit open, switching to fallback")
		result, err := s.fallbackPayment.Charge(ctx, order)
		if err == nil {
			return result, nil
		}
	}

	// Strategy 3: Both channels down → async processing
	// Return "processing", queue for background retry
	if order.Amount < 10000 { // small amounts can go async
		if err := s.enqueueAsyncPayment(order); err != nil {
			return nil, fmt.Errorf("payment unavailable, please retry later")
		}
		return &PaymentResult{
			Status:  "processing",
			Message: "Payment processing, please check results later",
		}, nil
	}

	// Large amounts can't go async — reject
	return nil, fmt.Errorf("payment service unavailable")
}

This code implements three-tier degradation: primary → fallback → async. Note that large orders (over 10,000) can’t use the async path — because if async payment ultimately fails, the refund process is complex and risky at high amounts. This was a real decision I made when building a payment system.

Configurable Degradation Switches

The most important capability in production is “one-click degradation.” Managed through config center (like Nacos, Apollo):

// Degradation config from config center
type DegradeConfig struct {
	// Feature toggles
	EnableRecommendation bool `json:"enable_recommendation"`
	EnableComments       bool `json:"enable_comments"`
	EnableCoupons        bool `json:"enable_coupons"`
	// Degradation strategy
	RecommendationDegrade string `json:"recommendation_degrade"` // "default" | "cache" | "disabled"
	// Rate limit thresholds (dynamically adjustable)
	OrderRateLimit int `json:"order_rate_limit"`
}

// DegradeManager
type DegradeManager struct {
	config *DegradeConfig
	mu     sync.RWMutex
}

// CheckFeature checks if a feature is enabled
func (dm *DegradeManager) CheckFeature(feature string) bool {
	dm.mu.RLock()
	defer dm.mu.RUnlock()
	switch feature {
	case "recommendation":
		return dm.config.EnableRecommendation
	case "comments":
		return dm.config.EnableComments
	case "coupons":
		return dm.config.EnableCoupons
	}
	return true // default enabled
}

// UpdateConfig hot-reload callback from config center
func (dm *DegradeManager) UpdateConfig(newConfig *DegradeConfig) {
	dm.mu.Lock()
	dm.config = newConfig
	dm.mu.Unlock()
	log.Printf("degrade config updated: %+v", newConfig)
}

After config changes, ops staff can one-click disable a feature through the Nacos console — no deployment, no restart needed.

Coordinating All Three: Production Traffic Governance Architecture

Rate limiting, circuit breaking, and degradation aren’t used in isolation — they form a multi-layer defense system. Here’s the architecture I’ve deployed in production:

                        User Request
                 ┌──────────────────┐
                 │   API Gateway    │  ← Global rate limiting (token bucket)
                 │  (Kong/APISIX)   │     Per-API, per-tenant
                 └────────┬─────────┘
              ┌───────────┼───────────┐
              ▼           ▼           ▼
         ┌────────┐ ┌────────┐ ┌────────┐
         │ Order  │ │ User   │ │ Search │  ← Service-level limiting (Sentinel/gobreaker)
         │Service │ │Service │ │Service │     Per-interface, per-user
         │        │ │        │ │        │
         │ CB-A   │ │ CB-B   │ │ CB-C   │  ← Circuit breaking on downstream calls
         └───┬────┘ └───┬────┘ └───┬────┘
             │          │          │
             ▼          ▼          ▼
         ┌────────┐ ┌────────┐ ┌────────┐
         │Payment │ │ Auth   │ │Database│  ← Resource-level protection
         │Service │ │Service │ │        │     Connection pool limiting, slow SQL breaking
         │Degrade │ │Degrade │ │        │
         └────────┘ └────────┘ └────────┘

Multi-Layer Defense Responsibilities

LayerMechanismToolConfiguration
GatewayGlobal rate limitingKong/APISIX/NginxPer-API, QPS = peak × 1.5
ServiceInterface rate limitingSentinel/Resilience4jPer-interface + per-user
CallCircuit breaking + retryResilience4j/gobreaker50% failure trip, max 2 retries
ResourceConnection pool limitHikariCP/Go sql.DBConnections = CPU cores × 2 + 1
BusinessDegradation switchesConfig centerProtect core, degrade non-core

Timeout Hierarchy

Timeouts must decrease from outer to inner layers, otherwise the outer layer times out and retries before the inner layer finishes:

API Gateway timeout 5s
    └─ Order service timeout 3s
        └─ Payment service timeout 2s
            └─ Third-party payment timeout 1.5s

Each layer reserves 0.5-1 second for its own processing. If the innermost timeout is 1.5s, the payment service timeout must be greater than 1.5s (e.g., 2s), otherwise the payment service times out before the third party returns.

Pitfall case: We had a production incident caused by inverted timeout hierarchy. Order service timeout was 3s, payment service timeout was 5s. While payment service was waiting for the third party (not timed out yet), the order service timed out after 3s waiting for payment service, then retried — sending another request to payment service. Two requests processing simultaneously on payment service caused database row lock conflicts, slowing down even more requests. We had to urgently reduce the payment service timeout to 2s to recover.

For alerting strategy design, see Related: Alerting Strategy Design — From Noise to Signal — well-designed alert rules can warn you before circuit breakers trip.

Production Pitfall Records

Pitfall 1: Traffic Surge After Circuit Recovery

When the breaker transitions from OPEN to HALF_OPEN, it allows probe requests. If probes succeed, it immediately switches to CLOSED — and all previously queued requests rush in at once, overwhelming the just-recovered downstream.

Solution: Implement gradual traffic ramp-up after recovery. Instead of going to 100% flow instantly, start with 10%, observe 30 seconds, then 30%, then 100%.

// Gradual recovery: control traffic ratio after breaker recovery
type GradualRecovery struct {
	mu              sync.Mutex
	recoveryPhase   int      // 0=closed, 1=10%, 2=30%, 3=100%
	phaseStartTime  time.Time
}

func (gr *GradualRecovery) Allow() bool {
	gr.mu.Lock()
	defer gr.mu.Unlock()

	ratios := []float64{0.1, 0.3, 1.0} // gradual ratios
	if gr.recoveryPhase >= len(ratios) {
		return true // fully recovered
	}

	// Check if should advance to next phase
	if time.Since(gr.phaseStartTime) > 30*time.Second {
		gr.recoveryPhase++
		gr.phaseStartTime = time.Now()
	}

	// Allow traffic by ratio
	ratio := ratios[gr.recoveryPhase]
	if rand.Float64() < ratio {
		return true
	}
	return false
}

Pitfall 2: Retry Amplification

As mentioned, A calls B with 3 retries, B calls C with 3 retries — traffic amplifies 9x. But there’s a sneakier version:

Scenario: Order service calls inventory service, timeout 2s, retry 3 times. Inventory service is perfectly fine — just a 1.5s GC pause. Order service times out and retries. Inventory service receives 3 identical requests and deducts inventory 3 times.

Solutions:

  1. Retries must be idempotent. Inventory deduction can’t be UPDATE stock SET count = count - 1 directly — use idempotency keys: check if the request ID was already processed, return the previous result if so.
  2. Limit total retries across the chain. Not 3 retries per layer, but 3 retries for the entire chain. Pass remaining retry count through request headers.
  3. Add jitter to retries. Don’t retry immediately — wait a random time (100ms-500ms) to avoid multiple callers retrying simultaneously.

Pitfall 3: Sentinel Rules Not Persisted

Sentinel stores rules in memory by default — they’re lost on restart. After a server restart with rules gone, a traffic peak hit and took down the downstream database.

Solution: Rules must be persisted to a config center (Nacos/Apollo). Sentinel pulls rules on startup, and rule changes are pushed to all instances.

Pitfall 4: No Alerting on Circuit Breaker State Changes

A breaker transitioning from CLOSED to OPEN is an important signal — it means something downstream is wrong. But without alerting, you might not know until users complain.

Solution: Push alerts in the OnStateChange callback. Not just for OPEN — HALF_OPEN and CLOSED too — knowing when recovery happened is equally important. Alert content should include: breaker name, state transition, current failure rate, trigger reason. (See Related: Alert Automation — From Alert Storms to Self-Healing Systems)

Pitfall 5: Envoy Ejection Causing Insufficient Instances

Envoy’s maxEjectionPercent: 50 means at most 50% of instances can be evicted. But if the service has only 2 instances, evicting 1 hits 50%, leaving only 1 instance to handle all traffic.

Solution: Lower the ejection ratio for small-scale services, or set minHealthPercent to 100 (no eviction, only load reduction). Another option: ensure at least 3 instances — easy in K8s environments.

Tool Comparison

ToolLanguageRate LimitingCircuit BreakingDegradationDashboardUse Case
SentinelJavaStrongStrongStrongYesJava ecosystem first choice
Resilience4jJavaMediumStrongMediumNoLightweight, modular
HystrixJavaWeakStrongMediumYesEnd-of-life, not recommended
gobreakerGoNoneStrongNoneNoGo circuit breaking
Envoy/IstioAgnosticStrongStrongMediumYesService Mesh approach
Kong/APISIXAgnosticStrongMediumWeakYesAPI gateway layer

My selection advice:

  • Pure Java microservices: Sentinel + Resilience4j. Sentinel for rate limiting and hot parameter control, Resilience4j for circuit breaking.
  • Pure Go microservices: gobreaker + custom rate limiting. Go ecosystem lacks a Sentinel-level comprehensive solution, but a self-implemented rate limiter + circuit breaker is sufficient.
  • Multi-language: Istio/Envoy at the mesh layer — zero code intrusion.
  • Don’t want to change code: API gateway rate limiting + Envoy circuit breaking, zero code intrusion.

Monitoring and Observability

After deploying rate limiting, circuit breaking, and degradation, you must be able to see their operational status. Key metrics to monitor:

MetricMeaningAlert Threshold
Rate limit rejection rateRejected / total requests> 5%
Circuit breaker stateOPEN/CLOSED/HALF_OPENOPEN > 1min
Circuit trip countOPEN transitions per unit time> 3/5min
Degradation trigger countDegradation logic executions> 100/min
Recovery timeOPEN → CLOSED duration> 5min

Prometheus + Grafana monitoring config:

# Prometheus scrape config for Resilience4j metrics
scrape_configs:
  - job_name: 'resilience4j'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['order-service:8080', 'payment-service:8080']
# Circuit breaker state (0=CLOSED, 1=OPEN, 2=HALF_OPEN)
resilience4j_circuitbreaker_state

# Circuit breaker failure rate
resilience4j_circuitbreaker_failure_rate

# Rate of available permissions (rate limiter)
rate(resilience4j_ratelimiter_available_permissions[1m])

# Alert rule: circuit breaker open for over 1 minute
ALERT CircuitBreakerOpen
IF resilience4j_circuitbreaker_state == 1
FOR 1m
LABELS { severity = "critical" }
ANNOTATIONS {
  summary = "Circuit breaker {{ $labels.name }} is open",
  description = "Circuit breaker has been open for over 1 minute, check downstream service status"
}

Capacity Assessment and Load Testing

Before configuring rate limit thresholds, you must know what your system can actually handle. Don’t just pick a number out of thin air.

Testing Methods

  1. Baseline test: Gradually increase load until you find the inflection point (where response time starts to spike). The QPS before the inflection point is your safe water level.
  2. Stress test: Keep increasing load until the system breaks. Record the max QPS before failure. Divide by 2-3 for your rate limit threshold reference.
  3. Mixed workload test: Simulate real traffic ratios (read:write = 8:2), not just single-interface tests.

Capacity Formula

Safe QPS = baseline inflection QPS × safety factor (0.6-0.7)
Rate limit threshold = Safe QPS × instance count × load imbalance factor (0.8)

Example: Single-instance baseline inflection is 2000 QPS, safety factor 0.7, safe QPS = 1400. 10 instances, load imbalance factor 0.8 (accounting for Nginx/K8s uneven distribution), rate limit threshold = 1400 × 10 × 0.8 = 11200 QPS.

For a complete capacity planning methodology, see Related: SLO Design — From Business Goals to Technical Metrics — align rate limit thresholds with SLO targets.

Summary

Microservice rate limiting, circuit breaking, and degradation is not a “install and forget” component — it’s a traffic governance system that requires continuous tuning. Returning to my experience managing 50+ microservices, here are the core principles:

Rate limiting: Token bucket at the gateway to allow bursts, Sentinel at the service layer for fine-grained control. Distributed limiting with Redis + Lua for atomicity. Thresholds must be based on load test data — never guess.

Circuit breaking: Resilience4j (Java) or gobreaker (Go) for application-layer, Envoy/Istio for mesh-layer. The key parameters are failureRateThreshold and slowCallDurationThreshold — the former determines sensitivity, the latter defines what counts as slow. minimumNumberOfCalls must be set to avoid small-sample false positives.

Degradation: Separate core and non-core paths. Degradation strategies must be pre-tested, with switches managed through config center. The return is a “second-best result,” not an error code.

Coordination: Multi-layer defense from outside in — gateway limiting, service limiting, call breaking, resource protection, business degradation. Timeouts decrease from outer to inner. Retries must be idempotent with jitter.

Observability: Circuit breaker state changes must trigger alerts. Rate limit rejection rate, circuit trip count, and degradation trigger count all need monitoring dashboards and alert rules.

One final lesson: after deploying rate limiting, circuit breaking, and degradation, you must run chaos engineering exercises — deliberately kill a service and see if your defense system works as expected. There’s a world of difference between paper plans and real failures. Don’t wait until 3 AM when alerts wake you up to discover your circuit breaker config is broken. For systematic approaches to failure drills, see Related: SRE Incident Preparedness and Drills.

References & Acknowledgments

This article referenced the following materials during writing. Thanks to the original authors for their contributions:

  1. Circuit Breaker Design Pattern — GitHub Topics, a collection of mainstream circuit breaker open-source projects including Sentinel, Resilience4j, gobreaker, etc.
  2. Spring Boot Microservice Circuit Breaking: 2026 Latest Guide — CSDN, provided detailed explanation of Resilience4j circuit breaker state machine model
  3. Timeout, Retry, and Circuit Breaking in Microservices — CSDN, provided analysis of timeout configuration and retry storms
  4. Degradation, Rate Limiting, and Graceful Failover for LLM Integration in Microservices — CSDN, provided multi-layer defense architecture design ideas
  5. Resilience4j Introduction — cnblogs, provided comparison and configuration of Resilience4j modules
  6. Performance Modeling of Microservices with Circuit Breakers using Stochastic Petri Nets — IEEE, academic paper studying quantified impact of circuit breakers on microservice performance, measured 71.4% reduction in request discard rate
  7. Self-Adaptive Circuit Breaker Open-State Interval for Enhancing Resiliency of Microservices — IEEE, academic paper proposing adaptive circuit breaker cooldown time