DEV Community

BlackyDoe
BlackyDoe

Posted on

Building a Retry Decorator for Flaky Functions

Network calls fail. APIs time out. Databases hiccup. Instead of wrapping every unreliable call in its own manual try/except loop, it's worth writing that logic once as a decorator you can drop on top of any function.

1. The core idea

A decorator wraps a function, catches exceptions, waits, and tries again — up to a configurable number of attempts.

import time
import functools

def retry(times=3, delay=1):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    last_error = e
                    print(f"Attempt {attempt} failed: {e}")
                    time.sleep(delay)
            raise last_error
        return wrapper
    return decorator
Enter fullscreen mode Exit fullscreen mode

@functools.wraps(func) preserves the wrapped function's name and docstring, so debugging tools and introspection still show the original function rather than a generic wrapper.

2. Why the decorator takes arguments

Notice retry isn't the decorator itself — it's a function that returns one. That's what allows @retry(times=3, delay=2) to accept parameters, instead of being locked into one fixed retry count for every use.

@retry(times=3, delay=2)
def fetch_data():
    import random
    if random.random() < 0.7:
        raise ConnectionError("timeout")
    return "success"
Enter fullscreen mode Exit fullscreen mode

Each call to fetch_data() now automatically retries up to 3 times, waiting 2 seconds between attempts, before giving up and re-raising the last error.

3. What happens on final failure

If every attempt fails, the decorator re-raises the last captured exception rather than swallowing it silently:

raise last_error
Enter fullscreen mode Exit fullscreen mode

This matters — a retry wrapper that hides failures after giving up would make debugging much harder. The caller still finds out the operation ultimately failed, just after a few automatic attempts.

4. The full script, start to end

import time
import functools
import random

def retry(times=3, delay=1):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_error = None
            for attempt in range(1, times + 1):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    last_error = e
                    print(f"Attempt {attempt} failed: {e}")
                    time.sleep(delay)
            raise last_error
        return wrapper
    return decorator

@retry(times=3, delay=2)
def fetch_data():
    if random.random() < 0.7:
        raise ConnectionError("timeout")
    return "success"

if __name__ == "__main__":
    print(fetch_data())
Enter fullscreen mode Exit fullscreen mode

5. Trying it out

A typical run might print:

Attempt 1 failed: timeout
Attempt 2 failed: timeout
success
Enter fullscreen mode Exit fullscreen mode

Since fetch_data fails 70% of the time in this example, three attempts usually — but not always — succeed.

Wrap-up

This is the simplest version of a retry pattern: fixed delay, fixed attempt count. Production systems typically want exponential backoff (doubling the delay each attempt) and jitter (a small random offset) to avoid many clients retrying in lockstep and overwhelming a recovering service. But as a foundation, this decorator captures the core idea in about a dozen lines, and it's easy to extend once you need more sophistication.

Top comments (0)