Python

Verified against Claude Code · 2026-07-31

Write a retry-with-backoff decorator that preserves the wrapped function correctly

A prompt for a generic retry decorator (sync and async variants) that preserves signatures and docstrings with functools.wraps, distinguishes retryable from fatal exceptions, and caps total wait time — instead of a naive while-loop that retries everything forever.

Claude CodeChatGPT (GPT-5.1)Cursor 2.14 fillable variables

The prompt

Ready to copy — highlighted parts are example details you can swap.

Write a retry-with-backoff decorator for the function(s) described below. This needs to be a decorator other functions can reuse, not a one-off retry loop copy-pasted into a single call site.

FUNCTION(S) TO WRAP
fetch_exchange_rate(currency: str) -> float, which calls an external HTTP API.

RETRYABLE VERSUS FATAL
Retry on httpx.TimeoutException and httpx.ConnectError; never retry on a 4xx client error (httpx.HTTPStatusError with status < 500).

BACKOFF REQUIREMENTS
Exponential backoff starting at 0.5s, doubling each attempt, max 5 attempts, capped total wait of 30s.

SYNC OR ASYNC
Async only — the wrapped function is always a coroutine function.

REQUIREMENTS
1. Use functools.wraps on the inner wrapper function, so the decorated function keeps its original __name__, __doc__, and signature — a decorator that doesn't do this breaks introspection, breaks Sphinx-generated docs, and makes stack traces confusing about which function actually failed.
2. Only retry the exceptions named in Retry on httpx.TimeoutException and httpx.ConnectError; never retry on a 4xx client error (httpx.HTTPStatusError with status < 500).; anything else propagates immediately on the first attempt. Naming an exception "fatal" and then silently retrying it anyway is worse than not having retry logic at all, because it delays a failure that should have surfaced immediately.
3. Implement exponential backoff with jitter (not fixed-interval retries) matching Exponential backoff starting at 0.5s, doubling each attempt, max 5 attempts, capped total wait of 30s., and enforce a hard cap on either the number of attempts or the total elapsed wait time — an unbounded retry loop against a dependency that's actually down just multiplies load on a struggling system instead of giving up cleanly.
4. Log each retry attempt at a level that won't spam production logs on transient blips but will surface a pattern of repeated failures — include the attempt number, the exception, and the wait time before the next attempt.
5. If Async only — the wrapped function is always a coroutine function. calls for both, write two decorators (or one that detects and dispatches correctly) — do not write a sync decorator and just slap async def on the inner call, since that produces a coroutine that's never awaited and silently does nothing.
6. On final failure after exhausting retries, raise the original exception (or wrap it in a custom RetriesExhausted exception that keeps the original as __cause__) — never swallow the failure and return None, which hides the failure from the caller entirely.
7. Make the decorator's parameters (max attempts, base delay, retryable exceptions) real arguments to the decorator factory, not constants baked into the wrapper's body — a decorator that can only be configured by editing its own source isn't actually reusable across the different call sites that will each have different tolerances for how long a retry sequence should run.
8. If the wrapped function accepts arguments that could be exhausted or invalidated by a retry (a request body that's actually a generator, consumed on the first attempt and empty on the second), name that risk explicitly rather than assuming every argument is safe to reuse across attempts.

OUTPUT FORMAT
1. The decorator, fully typed with functools.wraps applied, with its tunable parameters as factory arguments.
2. One example of it applied to a real function from fetch_exchange_rate(currency: str) -> float, which calls an external HTTP API..
3. A short note on what happens on the exception it should NOT retry, showing it propagates immediately.
4. Confirmation that every argument passed to the wrapped function is safe to reuse unchanged across every retry attempt, or a named exception to that.

Customize

Optional — swap in your own details for the highlighted parts above.

Why this works

Requiring functools.wraps addresses a specific, well-known Python foot-gun: a decorator written as a plain closure without it silently replaces the wrapped function's __name__, __doc__, and __wrapped__ attributes with the inner wrapper's own, so every decorated function in a codebase suddenly reports itself as "wrapper" in stack traces and to introspection tools like Sphinx or FastAPI's own route naming — a subtle bug that doesn't crash anything but makes debugging every function that uses this decorator measurably harder for as long as it exists. Separating retryable from fatal exceptions by name, rather than retrying anything that raises, targets the actual reason blanket retry logic is dangerous: retrying a 4xx client error (a malformed request, bad auth) delays a failure the caller needs to see immediately and burns through the retry budget on an error that will never succeed no matter how many times it's attempted, while a genuinely transient timeout is exactly what retry logic should absorb. Requiring exponential backoff with jitter and a hard cap, rather than fixed-interval retries with no ceiling, reflects real distributed-systems practice: fixed-interval retries from many callers synchronize into request bursts that hit a struggling dependency at the same moment, worsening exactly the load problem that's likely causing the failures in the first place, while jitter spreads retries out and a hard cap stops the decorator from participating in an outage indefinitely. The sync-versus-async dispatch requirement exists because the single most common bug in a hastily-written retry decorator is applying a synchronous wrapper to an async function — the call appears to succeed with no error, but it actually returns an un-awaited coroutine object, so the retry logic never executes and the failure is worse than having no decorator at all, since it looks like protection that isn't actually there. Making the tunable parameters real decorator-factory arguments rather than hardcoded constants is what actually makes the decorator reusable across call sites with genuinely different tolerances — a payment call and a cache warm-up have very different acceptable retry budgets, and a decorator that can't express that difference without editing its own source isn't a shared utility, it's one call site's retry loop wearing a decorator's clothes.

What you get back

def retry_with_backoff(*, retry_on: tuple[type[Exception], ...], max_attempts: int = 5, base_delay: float = 0.5, max_total_wait: float = 30.0): def decorator(func): @functools.wraps(func) async def wrapper(*args, **kwargs): total_wait = 0.0 for attempt in range(1, max_attempts + 1): try: return await func(*args, **kwargs) except retry_on as exc: if attempt == max_attempts: raise delay = min(base_delay * (2 ** (attempt - 1)) + random.uniform(0, 0.1), max_total_wait - total_wait) total_wait += delay logger.warning(f"{func.__name__} attempt {attempt} failed ({exc}); retrying in {delay:.2f}s") await asyncio.sleep(delay) return wrapper return decorator @retry_with_backoff(retry_on=(httpx.TimeoutException, httpx.ConnectError), max_attempts=5) async def fetch_exchange_rate(currency: str) -> float: ... On a 401 (httpx.HTTPStatusError, status 401): not in retry_on, so it propagates on the first attempt — no retries logged, no wasted retry budget on an error retrying can never fix.

Verified against

Claude Code Sonnet 4.6 · 2026-07-31

Changelog

  • 2026-07-31 Initial publish, verified against Claude Code (Sonnet 4.6) on Python 3.12 with httpx 0.28.

Need this built into your business?

If a prompt isn't enough — custom software, built and maintained for you — that's Scult's day job.

EXPLORE CUSTOM SOFTWARE
All Python prompts

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY