5 min read

How Uber Protects Against Retry Storms

How Uber Protects Against Retry Storms
Photo by Josep Castells / Unsplash

Anatomy of a Distributed Retry Storm

At massive web scale, distributed systems operate under the constant threat of partial network partitions, node evictions, and sudden garbage collection pauses. When an upstream microservice invokes a downstream dependency via synchronous protocols such as gRPC or HTTP/2 and encounters a timeout or a 5xx status code, the immediate instinct of the client is to reissue the request. While retries are an essential primitive for achieving eventual success in unreliable networks, uncoordinated retries introduce a profound systemic hazard: the retry storm.

Consider a downstream storage tier or authentication service experiencing a temporary performance degradation, causing its P99 latency to spike from 15 milliseconds to 800 milliseconds. Upstream services configured with aggressive timeout thresholds (e.g., 500 milliseconds) will prematurely abort their ongoing requests and immediately dispatch duplicate requests back into the ingress pipeline. This amplifies the incoming load on the struggling downstream service by a factor proportional to the retry budget. If three upstream callers each retry twice, the downstream service faces a 3x multiplication of traffic precisely when its internal resource pools—such as database connection pools, CPU threads, and memory arenas—are already exhausted.

This dynamic creates a positive feedback loop known as cascading failure. The additional load exacerbates resource contention, driving latencies even higher, which triggers more timeouts, spawning another wave of retries. Without deterministic traffic-shaping barriers, this loop rapidly drives the entire system into a total blackout state, necessitating manual intervention, traffic shedding, or cascading circuit breaking across the fleet.

The Mathematics of Exponential Backoff and Jitter

To prevent clients from aggressively hammering recovering infrastructure, distributed architectures rely on exponential backoff algorithms paired with pseudo-random jitter. A naive retry mechanism that attempts requests at fixed intervals (e.g., every 100ms) synchronizes the retry attempts of thousands of independent clients, creating destructive traffic spikes that periodically pound the target service in rhythmic waves.

Exponential backoff scales the wait time between subsequent retry attempts exponentially, typically calculated as:

sleep_time = min(max_backoff, base_interval * (multiplier ^ attempt_number))

However, pure exponential backoff is insufficient because concurrent clients that experience failure at the exact same moment will compute the exact same backoff values, maintaining their phase synchronization. To break this lockstep behavior, distributed systems inject jitter—introducing statistical variance into the backoff calculation. Engineers generally implement Full Jitter or Equal Jitter variants. Full Jitter randomizes the sleep duration uniformly between zero and the calculated exponential backoff ceiling:

sleep_time = uniform_random(0, min(max_backoff, base_interval * (2 ^ attempt_number)))

By randomizing the delay, the aggregate arrival rate of retried requests distributes across a continuous time window, effectively flattening the traffic spike and allowing the downstream service to drain its backlog safely.

Client-Side Rate Limiting and Token Bucket Algorithms

Relying solely on backoff and jitter leaves systems vulnerable if upstream clients generate load faster than downstream services can process it. To enforce strict boundaries on outbound request rates, high-scale architectures implement client-side rate limiting using the Token Bucket or Leaky Bucket algorithms directly within the service mesh sidecars or RPC client libraries.

The Token Bucket algorithm maintains a bucket of capacity b that fills with tokens at a constant rate r per second. Every outgoing RPC request must acquire a token from the bucket. If the bucket is empty, the request is either immediately failed with a client-side error (preventing wasted network I/O) or queued temporarily depending on latency sensitivity. In a distributed environment, these rate limiters can be coordinated globally using centralized caches like Redis, or operated autonomously per local node using local memory allocations and decentralized atomic counters.

Implementing client-side rate limiting shifts the burden of congestion control away from fragile downstream servers and places it onto the callers. This ensures that even under severe upstream traffic surges, downstream dependencies receive a stable, predictable volume of requests that matches their calibrated capacity limits.

Adaptive Concurrency Limits and Load Shedding

While static rate limiters protect against nominal overloads, modern distributed systems feature dynamic topologies where downstream capacity fluctuates due to autoscaling, noisy neighbors, or hardware degradation. Static limits are notoriously brittle in these environments; they either underutilize available capacity during quiet periods or allow destructive overloads when capacity dips. To solve this, architectures deploy adaptive concurrency limits based on Little's Law.

Little's Law states that the long-term average number of customers L in a stationary system is equal to the long-term average effective arrival rate $\lambda$ multiplied by the average time W that a customer spends in the system:

L = \lambda * W

By continuously measuring the round-trip time (RTT) of successful requests and tracking the in-flight concurrency, an adaptive client can dynamically calculate the optimal concurrency limit without requiring explicit server-side feedback. When the unweighted RTT begins to creep upward while concurrency remains constant, it signals that the downstream queue is filling. The client immediately throttles its concurrency window, dropping or rejecting requests locally before they consume network sockets or thread pools.

Architectural Enforcement via Service Mesh and Proxies

At enterprise scale, delegating retry policies, backoff logic, and circuit breaking to application developers invariably leads to misconfigurations, inconsistent behaviors, and systemic vulnerabilities. Modern backend engineering abstracts these resilience patterns into infrastructure layers, specifically utilizing sidecar proxies like Envoy configured within a service mesh.

Below is a production-grade Envoy configuration demonstrating how to enforce advanced retry policies, per-try timeouts, and circuit-breaking parameters at the network ingress/egress boundary:

static_resources:
  listeners:
  - name: inbound_service_listener
    address:
      socket_address:
        address: 0.0.0.0
        port_value: 8080
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: ingress_http
          route_config:
            name: dynamic_route
            virtual_hosts:
            - name: backend_service
              domains: ["*"]
              routes:
              - match:
                  prefix: "/"
                route:
                  cluster: downstream_core_cluster
                  timeout: 2s
                  retry_policy:
                    retry_back_off:
                      base_interval: 0.25s
                      max_interval: 5s
                    num_retries: 3
                    retry_on: "5xx,reset,connect-failure"
  clusters:
  - name: downstream_core_cluster
    connect_timeout: 0.25s
    type: STRICT_DNS
    lb_policy: ROUND_ROBIN
    circuit_breakers:
      thresholds:
        - priority: DEFAULT
          max_connections: 1024
          max_pending_requests: 512
          max_requests: 1024
          max_retries: 3
    load_assignment:
      cluster_name: downstream_core_cluster
      endpoints:
      - lb_endpoints:
        - endpoint:
            address:
              socket_address:
                address: core-service.internal
                port_value: 9000

This configuration enforces an explicit ceiling on destructive retry amplification. By defining a maximum of 3 retries with a base backoff of 250 milliseconds scaling up to 5 seconds, paired with strict circuit breaker thresholds (max_retries: 3), the proxy guarantees that a failing downstream node will not be overwhelmed by runaway proxy loops.

Conclusion and Production Best Practices

Protecting distributed systems against retry storms requires a defense-in-depth strategy that spans protocol design, algorithmic client-side control, and infrastructure-level enforcement. Senior engineers designing high-scale backends must internalize the following core tenets:

  • Never Retry Blindly: Enforce mandatory exponential backoff combined with full jitter to desynchronize client traffic patterns and eliminate rhythmic hammering.
  • Bound the Retry Budget: Implement global or per-request retry budgets that cap the percentage of retries allowed relative to total incoming traffic, preventing retry loops from consuming more than a fraction of system capacity.
  • Shift Logic to Infrastructure: Standardize resilience primitives into service mesh sidecars and API gateways rather than relying on disparate, ad-hoc application code implementations.
  • Embrace Fast Failures: Utilize client-side rate limiting and adaptive concurrency control to drop unprocessable requests early, preserving system memory, thread pools, and network bandwidth for healthy transactions.

By treating network instability as a first-class architectural constraint, engineering teams can build resilient distributed systems that gracefully absorb infrastructure degradation without escalating into catastrophic cascading outages.