Nothing broken. Two mechanics that the answer is built from — read what the bare raise on its own line does.
def read_twice(source):
for attempt in (1, 2):
try:
return source() # success leaves the loop AND the function
except Exception:
if attempt == 2:
raise # bare: re-raise the one we just caught
# raise ValueError(err) would replace the traceback with a shorter one.
# raise on its own keeps the original, all the way down to the real cause.
- return inside try ends everything
If the call works, the return happens immediately — the loop does not run again and no counter needs resetting. That is why 'stop as soon as it works' needs no code of its own; it falls out of where the return sits.
- A bare raise re-raises what you are already handling
Inside an except block, `raise` with nothing after it sends the current exception onward with its original traceback intact. Writing `raise error` instead re-raises it from here, and the caller loses the line that actually failed — which is the line they needed.
- except Exception is not the same as except:
A bare `except:` also catches KeyboardInterrupt and SystemExit, so Ctrl-C during a retry loop would be swallowed and retried. `except Exception` catches ordinary errors and lets those through. Broad here is correct — a retry helper genuinely cannot know what its callable raises — but broad has a floor.
You need to try the same thing several times and stop at the first success. What loops a known number of times, and what happens to a loop when you return from inside it?
Loop over the attempt numbers. Inside, try to return func(). Catch the failure — and the only question left is what to do on the last attempt, because that one must not be swallowed.
Guard attempts < 1 first. Then for each attempt number from 1 to attempts: try to return func(); on except Exception, if this was the last attempt, `raise` on its own line to let it out. Otherwise let the loop go round again.
def retry(func, attempts): if attempts < 1: raise ValueError(...) for number in range(1, attempts + ____): try: return ____() except Exception: if number == ____: ____
def retry(func, attempts):
if attempts < 1:
raise ValueError(f"attempts must be at least 1, got {attempts}")
for number in range(1, attempts + 1):
try:
return func()
except Exception:
if number == attempts:
raise
The safe-int exercise argued for naming the exceptions you expect. This one catches Exception, because a retry helper genuinely cannot know what its callable raises — that is the caller’s business. The rule was never 'always be narrow'; it is 'be as narrow as your knowledge allows'. Here that knowledge is zero, so Exception is honest. It still is not a bare except.
Every other failure is deliberately swallowed, which is the point of retrying. Tracking which attempt you are on — rather than counting down or catching-and-storing — keeps that decision in one visible place. The alternative shape, saving the last exception and raising it after the loop, works too and needs one more variable plus an unreachable line that linters complain about.
Real retries wait between attempts, and back off — 1s, 2s, 4s — so a struggling service is not hammered by every client at once. That is omitted here only because a sleep would make these tests slow, not because it is optional. Retrying instantly three times against an overloaded API is closer to a denial of service than to resilience.
Never retry an operation that is not safe to repeat. Reading is fine; charging a card is not, unless the API supports an idempotency key — otherwise a timeout on a request that actually succeeded bills the customer twice. Never retry a 4xx either: a 400 or a 403 will fail identically forever and you are just burning time. And in production, use tenacity or your HTTP client’s built-in retry rather than this — they handle backoff, jitter and per-status rules that took people years to get right.
- Add a delay with exponential backoff — 1, 2, 4 seconds — then add jitter and work out what problem the randomness is solving.
- Let the caller say which exceptions are worth retrying, so a 404 fails immediately while a timeout is tried again.
- Turn it into a decorator, @retry(attempts=3), so a function can declare its own retry policy — and read about functools.wraps before you do.