Let's Learn GenAI
  • Learn0 topics
  • Techniques0 topics
  • Courses0 courses
    View all courses →
  • GenAI Guide
    AI Career Path
    Paid AI Models & Tools
    Free AI Models & Tools
    Interview Preparation
    AI Career Path0 items
    View all paths →
  • Resources
    ArticlesResearch, releases & insight.NewsletterCurated AI, to your inbox.BenchmarkTop models, ranked.
Newsletter
  1. Home
  2. /Interview Prep
  3. /Coding
  4. /Retry Handler

Retry Handler

Implement a configurable retry handler with exponential backoff, jitter, and a max-delay cap to prevent thundering herds and unbounded blocking.

Last updated: September 2026

Implement a configurable retry handler with exponential backoff and jitter. Fixed delays hammer a struggling server; exponential backoff gives it progressively more breathing room. Jitter randomizes the delay so all retrying clients do not wake up simultaneously (thundering herd). max_delay caps the wait to prevent unbounded blocking.

“Exponential backoff without jitter creates synchronized thundering herds. Jitter is not optional - it is the point of not making a struggling server worse. Never retry non-retryable errors. GET is always safe to retry; POST requires an idempotency key.”

Clarifying Questions (Ask These First)

QuestionWhy it matters
After all retries exhausted: raise, return default, or call fallback?Determines the contract callers depend on - raising is the safest default
Is jitter range flexible, or is 10% of delay a fixed requirement?10% is a conservative start; full jitter (0 to delay) is more aggressive and often better
Which exceptions should trigger a retry?Retryable: 503, 429, timeout. Non-retryable: 400, 401, 404 - retrying will never help
Is the operation idempotent?GET and DELETE can be retried freely. POST and PATCH need an idempotency key or risk duplicates

Implementation

import time
import random
from typing import Callable, Any

class RetryHandler:
    def __init__(self, max_retries: int = 5, base_delay: float = 1.0, max_delay: float = 30.0):
        self.max_retries = max_retries
        self.base_delay = base_delay
        self.max_delay = max_delay

    def execute(self, func: Callable, *args, **kwargs) -> Any:
        last_exception = None
        for attempt in range(self.max_retries):
            try:
                return func(*args, **kwargs)
            except Exception as e:
                last_exception = e
                if attempt == self.max_retries - 1:
                    break
                delay = min(self.base_delay * (2 ** attempt), self.max_delay)
                jitter = random.uniform(0, delay * 0.1)
                time.sleep(delay + jitter)
        raise RuntimeError(f"Failed after {self.max_retries} attempts") from last_exception


# Usage
call_count = {"n": 0}

def flaky_function():
    call_count["n"] += 1
    print(f"  Attempt #{call_count['n']}")
    if call_count["n"] < 3:
        raise Exception("Simulated failure")
    return "Success!"

handler = RetryHandler(max_retries=3, base_delay=0.5, max_delay=5.0)
result = handler.execute(flaky_function)
print(f"Result: {result}")

Key Design Decisions

  • 1Exponential Backoff - Progressive Breathing Room - delay = min(base_delay * 2^attempt, max_delay). Attempt 0: 0.5s. Attempt 1: 1s. Attempt 2: 2s. Attempt 3: 4s. Fixed delay keeps hammering a struggling server at full rate. Exponential gives it progressively more time to recover.
  • 2Jitter - Preventing Thundering Herd - jitter = random.uniform(0, delay * 0.1). Without jitter, 1000 clients that all started retrying at the same moment sleep for exactly the same duration and wake up simultaneously - making the overloaded server 1000x worse at that instant. Jitter spreads the wake-ups.
  • 3max_delay Cap - Bounding the Wait - min(..., max_delay) prevents 2^attempt from growing unboundedly. Without it, attempt 30 would sleep for 2^30 * base_delay - over a billion seconds. In languages without Python's arbitrary-precision integers this is also an integer overflow bug. max_delay sets a practical ceiling.
  • 4Exception Chain - Preserving Root Cause - raise RuntimeError(...) from last_exception preserves the original exception as __cause__. Without the from clause, the original error is lost and debugging becomes much harder. Always chain exceptions in retry wrappers.

Frequently asked questions

Why exponential backoff instead of a fixed delay?

Fixed delay keeps hammering a struggling server at a constant rate - if it is overloaded at 1 second, it is still overloaded at 2, 3, 4 seconds. Exponential backoff gives the server progressively more time to recover. At attempt 5 with base 1s, the server gets 16 seconds of breathing room.

Why add jitter at all?

Without jitter, all clients that started retrying at the same time sleep for exactly the same duration and wake up simultaneously - thundering herd. If 500 services all failed at once and retry at exactly T+1s, the server gets hammered by 500 simultaneous requests. Jitter spreads the load across a time window.

What is the tradeoff of a very high max_delay?

Better for the server - more recovery time. Worse for the caller - they block longer. For user-facing APIs, a 30-second max delay is unacceptable. For background jobs or async queues, 5 minutes is fine. max_delay should match the caller's latency tolerance, not server preference.

How would you decide which exceptions to retry?

Split into two buckets. Retryable: server overloaded (503), rate limited (429), network timeout - the problem is temporary and will likely self-resolve. Non-retryable: bad request (400), unauthorized (401), not found (404) - retrying will never help. Define RetryableError and NonRetryableError custom exceptions, map HTTP status codes to them at the network layer, and raise immediately for non-retryable errors before entering the retry loop at all.

What if you only have an HTTP status code, not a typed exception?

Build a simple helper: def is_retryable(status: int) -> bool: return status == 429 or (500 <= status < 600). 5xx codes mean the server failed and retrying makes sense. 429 is rate-limited - also retryable, and check for a Retry-After header to use as sleep duration instead of calculating backoff yourself. 4xx codes except 429 are caller errors - raise immediately.

Would you retry a POST the same way as a GET?

No. GET is read-only - retrying 100 times is safe. POST is dangerous: retrying a payment POST could charge the customer twice, retrying an order POST creates duplicate orders. The right pattern: use an idempotency key - a unique UUID generated client-side, sent as a request header. The server deduplicates using this key and returns the original response on replay. Only retry POST if the API supports idempotency keys, and send the same key on every retry attempt.

Where this skill is used

AI roles that rely on this day to day, with salaries and the path in.

  • AI Reliability Engineer
  • MLOps Engineer
  • LLM Engineer
  • AI Copilot Engineer
  • Forward Deployed Engineer
  • AI Infrastructure Optimizer

Related coding challenges

  • Rate LimiterDesign a thread-safe sliding-window rate limiter with per-user and global call limits using timestamp lists and a threading lock.
  • RAG PipelineImplement a retrieval-augmented generation pipeline: ingest documents, chunk with overlap, embed, and retrieve top-K similar chunks via dot-product similarity.

Newsletter

Four editions, one inbox

The Build Layer for developers, The Strategy Signal for managers, The Executive Brief for executives.

Pick your edition

Guided courses

Get certified, not just informed

Guided courses from beginner to advanced, each with a named certificate. Free to take, yours to keep.

Browse courses
Let's Learn GenAI

Your guided portal to AI fundamentals, advanced techniques, and industry resources.

Learn

FundamentalsTechniquesCareersPaid AI ToolsFree AI ToolsBenchmarks

Explore

ArticlesNewsletterAbout

Legal

Terms of ServicePrivacy Policy

© 2026 Let's Learn GenAI. All rights reserved.