LESSON 145 · DEEPER PYTHON30 min

Retry Transient Failures with Timeouts and Backoff in Python

Retry only transient local failures with a bounded exponential delay schedule.

Example check: Worked and guided code samples are checked against CPython 3.13. Practice uses Pyodide 0.29.5 (Python 3.13) in your browser. How we check lessons

Video lesson: Retry Transient Failures with Timeouts and Backoff in Python

The preview is stored on this site. YouTube loads only when you press Play. Watch on YouTube

Read the video transcript

Select a timestamp to watch that moment on YouTube.

  1. 00:00

    Imagine a local operation that raises TimeoutError twice and succeeds on its third call. We want to retry because the failures might be temporary. Now imagine a different operation that raises ValueError because its input is invalid. Should we also call that repeatedly? The answer should be no: repeating a permanent bad input usually does not repair it. In this lesson, every operation is an injected local callable, so we can practice retry logic without touching a network or waiting in real time. Our result will have a maximum number of attempts, a clear list of retryable exception types, and an exponential delay schedule passed to a testable callback.

  2. 00:44

    A timeout means an operation did not finish within its configured limit; a connection error means a transport failed before a useful result. Both may be transient, but retrying forever is dangerous. This lesson accepts a local callable that either returns a value or raises TimeoutError or ConnectionError. It makes at most max_attempts calls. Between attempts it passes a calculated delay to an injected wait callback, which lets us test the schedule without sleeping or contacting a network. The actual operation remains responsible for its own timeout setting; this wrapper reacts to the TimeoutError it raises.

  3. 01:25

    The first program catches Exception, the broad base class for ordinary Python errors. Its fake operation raises ValueError once, then returns ready. The wrapper prints a retry message and ultimately appears successful. This is a misleading outcome: the initial error described invalid input, not a transient transport issue. The example is deliberately small enough to expose the policy mistake. In production, a broad retry can hide a KeyError, TypeError, or authentication problem and consume extra resources without helping the user. A retry decision is not just a loop control choice; it is a claim about which

  4. 02:05

    failures could plausibly improve later. The broken retry loop catches every Exception. A ValueError from invalid input gets retried even though repeating the same bad input will not fix it. A broad handler can also hide programming errors such as a wrong key or type. The demonstration uses a fake operation that raises ValueError once and then returns a value; the broken wrapper reports apparent success. That result is misleading because the first error should have been surfaced. We will print every call and distinguish a transient failure from a permanent application failure before deciding what the wrapper is allowed to catch.

  5. 02:47

    The diagnosis lists TimeoutError, ConnectionError, and ValueError with an explicit retryable flag. We choose the first two for this exercise. A timeout may indicate a slow operation, and a connection error may indicate a temporary link failure. Even these are not guaranteed to recover, which is why max_attempts matters. ValueError represents an invalid application input and passes through immediately. Be careful with wording: our wrapper does not create a timeout or cancel a long- running operation. The injected operation must enforce its own timeout and raise TimeoutError. This wrapper only handles that signal after it occurs.

  6. 03:29

    Catch only TimeoutError and ConnectionError. After a caught failure, if the current call was the last allowed attempt, re- raise that failure. Otherwise calculate base_delay times two to the power of the zero-based attempt number. With base_delay 0.5 and three attempts, the waits are 0.5 then 1.0 seconds in the abstract schedule. The injected wait function records those values during practice, so nothing actually sleeps. In a real service you may add jitter and a maximum delay to avoid many clients retrying together; this compact lesson isolates the bounded exponential idea.

  7. 04:09

    The fixed function validates its policy and loops over a bounded range. It returns immediately on success. When a retryable exception appears, it re- raises on the last allowed attempt. On a nonfinal failure it calculates base_delay times two to the attempt index, then passes that number to wait. With base delay half a second and three attempts, the waits recorded are half a second and one second. The demo uses list.append as the wait callback, so the explanation can show precise numbers without sleeping. In a real service, you might provide a sleeping callback and add a maximum delay and jitter; that is a separate operational decision.

  8. 04:52

    The control flow is intentionally narrow. Parameter validation happens before the first operation call, so invalid retry settings do not partly execute work. A successful operation returns directly. The except tuple catches only the two chosen transient signals. If the loop is at its final index, a bare raise preserves the original failure for the caller. Otherwise, the callback receives an exponentially increasing delay. The callback is injected rather than hardcoded to time.sleep, which lets a deterministic test record the schedule and lets other callers choose a different policy. Because the operation itself owns timeout enforcement, this wrapper never falsely

  9. 05:36

    promises to stop a call that is still running. The final run verifies two important boundaries. A ValueError is surfaced after one call, with no retries and no waits. A TimeoutError that persists through the limit is surfaced after exactly two calls in that test, with one wait between them and no pointless wait afterward. Also try an operation that succeeds immediately; the wait list should stay empty. Set max_attempts to zero, True, or 1.5, or base_delay to a negative number, and confirm ValueError. These checks make the retry policy observable. On the lesson page, complete the function and use the quiz to explain why catching

  10. 06:19

    every exception is unsafe and why the number of waits is one less than the number of attempted calls in a fully failing run. Validation is part of reliability. Reject max_attempts unless it is a positive integer (bool and float are invalid), and reject negative base_delay, because silent loops with zero calls or negative waits are not meaningful. The verification run checks a permanent ValueError is attempted only once and that exhausted transient retries surface the final TimeoutError. It also checks that wait is never called after the final failure or after immediate success.

  11. 06:59

    Retrying is an operational policy, not a substitute for validating inputs or handling an API's nonretryable status. The later offline API client project combines this policy with response parsing, still using only fake local transport.

Understand the concept

A timeout means an operation did not finish within its configured limit; a connection error means a transport failed before a useful result. Both may be transient, but retrying forever is dangerous. This lesson accepts a local callable that either returns a value or raises TimeoutError or ConnectionError. It makes at most max_attempts calls. Between attempts it passes a calculated delay to an injected wait callback, which lets us test the schedule without sleeping or contacting a network. The actual operation remains responsible for its own timeout setting; this wrapper reacts to the TimeoutError it raises.

The broken retry loop catches every Exception. A ValueError from invalid input gets retried even though repeating the same bad input will not fix it. A broad handler can also hide programming errors such as a wrong key or type. The demonstration uses a fake operation that raises ValueError once and then returns a value; the broken wrapper reports apparent success. That result is misleading because the first error should have been surfaced. We will print every call and distinguish a transient failure from a permanent application failure before deciding what the wrapper is allowed to catch.

Catch only TimeoutError and ConnectionError. After a caught failure, if the current call was the last allowed attempt, re-raise that failure. Otherwise calculate base_delay times two to the power of the zero-based attempt number. With base_delay 0.5 and three attempts, the waits are 0.5 then 1.0 seconds in the abstract schedule. The injected wait function records those values during practice, so nothing actually sleeps. In a real service you may add jitter and a maximum delay to avoid many clients retrying together; this compact lesson isolates the bounded exponential idea.

Validation is part of reliability. Reject max_attempts unless it is a positive integer (bool and float are invalid), and reject negative base_delay, because silent loops with zero calls or negative waits are not meaningful. The verification run checks a permanent ValueError is attempted only once and that exhausted transient retries surface the final TimeoutError. It also checks that wait is never called after the final failure or after immediate success. Retrying is an operational policy, not a substitute for validating inputs or handling an API's nonretryable status. The later offline API client project combines this policy with response parsing, still using only fake local transport.

  • Distinguish transient from permanent errors
  • Cap total attempts
  • Double delay between retries
  • Inject waiting for deterministic tests

See it step by step

Read the code, predict the output, then compare it with the result.

01. Count fake timeouts

PYTHON
calls = []
def local_operation():
    calls.append(1)
    if len(calls)<3: raise TimeoutError('slow')
    return 'ready'
for _ in range(3):
    try: print(local_operation())
    except TimeoutError: print('timeout')
EXPECTED OUTPUT
timeout
timeout
ready

A deterministic local callable models two transient failures followed by success.

02. Calculate doubling delays

PYTHON
base_delay = 0.5
print([base_delay * 2**attempt for attempt in range(2)])
EXPECTED OUTPUT
[0.5, 1.0]

Three total attempts need only two waits: after the first and second failure.

03. Record instead of sleep

PYTHON
delays = []
wait = delays.append
wait(0.5); wait(1.0)
print(delays)
EXPECTED OUTPUT
[0.5, 1.0]

An injected callback makes timing policy testable without a real pause.

A closer look

Follow the reasoning, inspect each result, then try the suggested changes in the console below.

01 / 03

Model transient calls

A timeout means an operation did not finish within its configured limit; a connection error means a transport failed before a useful result. Both may be transient, but retrying forever is dangerous. This lesson accepts a local callable that either returns a value or raises TimeoutError or ConnectionError. It makes at most max_attempts calls. Between attempts it passes a calculated delay to an injected wait callback, which lets us test the schedule without sleeping or contacting a network. The actual operation remains responsible for its own timeout setting; this wrapper reacts to the TimeoutError it raises.

The fake operation changes outcome based on its call count. This models a temporary outage while keeping the example repeatable. Do not assume every exception is temporary; the later task deliberately raises ValueError to test that policy boundary.

PYTHON
calls = []
def local_operation():
    calls.append(1)
    if len(calls)<3: raise TimeoutError('slow')
    return 'ready'
for _ in range(3):
    try: print(local_operation())
    except TimeoutError: print('timeout')
EXPECTED OUTPUT
timeout
timeout
ready
Follow the reasoning
  1. Count the fake calls.
  2. Classify which exceptions are transient.
02 / 03

Calculate a bounded schedule

The broken retry loop catches every Exception. A ValueError from invalid input gets retried even though repeating the same bad input will not fix it. A broad handler can also hide programming errors such as a wrong key or type. The demonstration uses a fake operation that raises ValueError once and then returns a value; the broken wrapper reports apparent success. That result is misleading because the first error should have been surfaced. We will print every call and distinguish a transient failure from a permanent application failure before deciding what the wrapper is allowed to catch.

Backoff doubles delay after each retryable failure, but the number of attempts is fixed. For three attempts, there can be at most two waits. Write out the schedule on paper before implementing the loop so an off-by-one error is easier to spot.

PYTHON
base_delay = 0.5
print([base_delay * 2**attempt for attempt in range(2)])
EXPECTED OUTPUT
[0.5, 1.0]
Follow the reasoning
  1. Compute delay after each nonfinal failure.
  2. Confirm there are only two waits for three calls.
03 / 03

Test without waiting

Catch only TimeoutError and ConnectionError. After a caught failure, if the current call was the last allowed attempt, re-raise that failure. Otherwise calculate base_delay times two to the power of the zero-based attempt number. With base_delay 0.5 and three attempts, the waits are 0.5 then 1.0 seconds in the abstract schedule. The injected wait function records those values during practice, so nothing actually sleeps. In a real service you may add jitter and a maximum delay to avoid many clients retrying together; this compact lesson isolates the bounded exponential idea.

Rather than calling time.sleep inside the browser exercise, pass a recorder such as list.append. Verify exact delay values, a zero-wait success, and no wait after the final failed attempt. A real caller could supply a sleeping function if appropriate.

PYTHON
delays = []
wait = delays.append
wait(0.5); wait(1.0)
print(delays)
EXPECTED OUTPUT
[0.5, 1.0]
Follow the reasoning
  1. Inject list.append as wait.
  2. Check immediate success and final failure.

Try it in Python

Edit the example and run it. Python starts in your browser the first time you click Run.

Python console

Ready to run
Need input()? Add one value per line
Ctrl / ⌘ + Enter to run
OUTPUT
Your output appears here.
Your turnImplement retry_transient. Retry only TimeoutError and ConnectionError for at most max_attempts calls; on a nonfinal failure call wait(base_delay * 2**attempt_index). Re-raise the final transient failure and reject a nonpositive or noninteger attempt count or negative delay.
Need a hint?

Use except (TimeoutError, ConnectionError); inside it, re-raise when attempt is the last one, otherwise call injected wait with a doubled delay.

Complete the task and select Check task to verify your code.

    Quick quiz

    Three questions. You can change your answers and try again.

    01.Should a ValueError caused by invalid input be retried here?

    02.With base delay 0.5 and three attempts, what waits occur after two failures?

    03.Why inject a wait callback in the lesson?

    Typical mistakes

    Everyone meets these errors. See what causes them and how to fix them.

    Retrying every exception

    COMMON MISTAKE
    except Exception: continue
    FIX
    except (TimeoutError, ConnectionError)

    What happens: Invalid input and programming bugs are hidden and repeated.
    State which failures might improve on a later attempt.

    Sleeping after the final failure

    COMMON MISTAKE
    wait(delay) before checking attempt limit
    FIX
    if final_attempt: raise; else: wait(delay)

    What happens: The caller waits even though no next call will occur.
    A delay is useful only before another attempt.

    Browse this lesson’s problem fixes

    Sources and further reading