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
@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"
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
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())
5. Trying it out
A typical run might print:
Attempt 1 failed: timeout
Attempt 2 failed: timeout
success
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)