Skip to content

My server ignored the port I set

  • Python · Foundations
  • 20 min read, about 1 h 15 min with practice
  • Concept
  • Asked often: medium
  • Reviewed 2026-11

New to functions? Open the quick recap. It takes about two minutes.

Quick recapFunctions in two minutes· 2 min

def makes a function. Parameters name the values it’s given, and return hands a value back. A function can call another one: the caller waits until the call it made gives back its answer.

def double(n):
return n * 2
def describe(n):
return f"{n} doubled is {double(n)}" # describe() waits for double()
describe(4) # '4 doubled is 8'

Your web server reads its port from a settings file. If the file doesn’t set one, it should use 8000. You wrote this, so a bad setting can’t crash the server:

def read_port(settings):
try:
return int(settings["port"])
except:
return 8000

A teammate sets port = 8O80, with a letter O in it. The server starts. Nobody can reach it on 8080, and there’s nothing in the logs. It quietly started on 8000.

The bare except: catches every error and throws it away: a bad setting, a missing one, even a typo in your own code. Then 70000 and 0 come back as they are, and fail later, far away from the setting. How do you catch only the error you expect, and say clearly what’s wrong?

Write read_port(settings), and a ConfigError the server can catch.

  • Input: settings, a dict of text read from a file. "port" may be missing.
  • Output: the port, a whole number from 1 to 65535. When "port" isn’t set, 8000.
  • The hard part: a port that is set but can’t be used raises ConfigError, with the bad value in its message. Spaces around the number are fine, as int() allows them.

Examples: read_port({"port": "8080"}) gives 8080, and read_port({}) gives 8000. read_port({"port": "8O80"}) raises ConfigError: port must be a whole number, got '8O80'.

Hints open one at a time:

Hint 1 of 3

When a line raises, Python stops that line, and that function, unless the line is inside a try with a matching except.

Hint 2 of 3

An except names the errors it catches. except ValueError catches ValueError, and errors made from it, but not KeyError.

Hint 3 of 3

Inside except, raise a clearer error of your own. raise ConfigError(...) from err keeps the ValueError as its cause.

An exception is an object that says what went wrong. raise sends it on its way. Python stops the line that raised it, then goes back through the calls in progress, newest first. The first try around the running line with a matching except catches it, and Python goes on from that except. A call with no try around the line just stops. If nothing catches it, the program stops and prints a traceback: the calls it went through, oldest first.

Watch an error go up. Each box is a call in progress, inside the call that made it. Type your own port text under the picture: try 8080, 70000 or 8 0.

An error on its way up
  1. Top levelrunning now

Step 1 of 11. ConfigError is a new kind of error, made from ValueError, so a ConfigError is a ValueError too. Lines 4 to 14 only define functions: nothing in them runs yet.

class ConfigError(ValueError):
    pass
 
def read_port(text):
    try:
        port = int(text)
    except ValueError as err:
        raise ConfigError(f"not a number: {text!r}") from err
    if not 1 <= port <= 65535:
        raise ConfigError(f"out of range: {port}")
    return port
 
def load_config(settings):
    return {"port": read_port(settings["port"])}
 
try:
    print(load_config({"port": "abc"}))
except ConfigError as err:
    print("Bad config:", err)

Try your own input

Errors are a family tree. class ConfigError(ValueError) makes a new kind of error from ValueError, so except ValueError catches a ConfigError too. Near the top, Exception is the parent of almost every error, bugs like NameError and TypeError included. Above it sits BaseException, the parent of KeyboardInterrupt (Ctrl+C) and SystemExit. A bare except: catches those too, so even Ctrl+C can be caught and thrown away.

A try has four parts. Only try and one of the others are needed:

try:
port = int(text) # the line that might fail
except ValueError as err: # runs only if it raised ValueError; err is the error
print("bad port:", err)
else: # runs only if nothing was raised
print("port is", port)
finally: # runs every time, even after a return
print("checked")

Keep the try block small: one line that can fail, not ten. Code that only runs when it worked goes in else.

Say what went wrong, and keep why. Inside an except, raise ConfigError(...) from err raises your clear error and keeps the first one as its cause. The traceback then shows both: what your code says, and what int() said.

All three give back 8080 for "8080" and 8000 when the port isn’t set. What changes is what happens to a bad port.

Okay

Catch everything, use the default

The idea: Wrap int(settings["port"]) in try, and give back 8000 from a bare except.

A missing port and a bad port both end up as 8000, and the server never crashes.

class ConfigError(ValueError):
"""A setting that is there but can't be used."""
def read_port(settings: dict[str, str]) -> int:
"""The port to listen on: 8000 when it isn't set, or a whole number from 1 to 65535."""
try:
return int(settings["port"])
except: # noqa: E722 (catches every error, typos and Ctrl+C included)
return 8000
Time
O(1)
Space
O(1)
Why
One look-up and one int() on a short text.

Where it breaks: it hides every mistake. "8O80" quietly becomes 8000, "70000" and "0" come back as they are, and a typo like settings["prot"] would be swallowed too. Ruff flags a bare except: as E722.

Better

Check first, then raise

The idea: Return 8000 if port isn’t set. If the text isn’t all digits, or the number is out of range, raise ConfigError.

The waste in Okay: it throws away the one thing you need, what went wrong, when the caller could be told.

Now a bad port raises ConfigError with the bad value in its message, and a missing one gives 8000. This is called look before you leap: check that it will work, then do it.

Highlighted lines are new in this rung.

class ConfigError(ValueError):
"""A setting that is there but can't be used."""
def read_port(settings: dict[str, str]) -> int:
"""The port to listen on: 8000 when it isn't set, or a whole number from 1 to 65535."""
if "port" not in settings:
return 8000
text = settings["port"]
if not text.isdigit(): # looks before it leaps: " 8080" and "8080\n" fail here
raise ConfigError(f"port must be a whole number, got {text!r}")
port = int(text)
if not 1 <= port <= 65535:
raise ConfigError(f"port must be from 1 to 65535, got {port}")
return port
Time
O(1)
Space
O(1)
Why
One look-up, one check of the text and one int().
What changed from the previous rung
class ConfigError(ValueError):
"""A setting that is there but can't be used."""
def read_port(settings: dict[str, str]) -> int:
"""The port to listen on: 8000 when it isn't set, or a whole number from 1 to 65535."""
try:
return int(settings["port"])
except: # noqa: E722 (catches every error, typos and Ctrl+C included)
if "port" not in settings:
return 8000
text = settings["port"]
if not text.isdigit(): # looks before it leaps: " 8080" and "8080\n" fail here
raise ConfigError(f"port must be a whole number, got {text!r}")
port = int(text)
if not 1 <= port <= 65535:
raise ConfigError(f"port must be from 1 to 65535, got {port}")
return port

Where it breaks: the check and int() disagree. isdigit() says no to " 8080 " and "8080\n", which int() reads fine, and says yes to "²", which int() refuses with a plain ValueError. And the ConfigError has no cause, so the traceback can’t say what int() saw.

Best

Try int(), catch ValueError, raise from it

The idea: Call int() inside try. On ValueError, raise ConfigError from it. Check the range after the try.

The waste in Better: it writes its own rule for what a number looks like, when int() already knows, and knows better.

This is easier to ask forgiveness than permission: try it, and catch the one error it raises. The try holds one line, so a ValueError can only mean the text isn’t a number. The range check sits after the try, so its ConfigError isn’t caught there. settings.get("port") gives None when the port is missing, with no KeyError to catch.

Highlighted lines are new in this rung.

class ConfigError(ValueError):
"""A setting that is there but can't be used."""
def read_port(settings: dict[str, str]) -> int:
"""The port to listen on: 8000 when it isn't set, or a whole number from 1 to 65535."""
text = settings.get("port")
if text is None:
return 8000
try:
port = int(text)
except ValueError as err:
raise ConfigError(f"port must be a whole number, got {text!r}") from err
if not 1 <= port <= 65535:
raise ConfigError(f"port must be from 1 to 65535, got {port}")
return port
Time
O(1)
Space
O(1)
Why
One look-up and one int() on a short text.
What changed from the previous rung
class ConfigError(ValueError):
"""A setting that is there but can't be used."""
def read_port(settings: dict[str, str]) -> int:
"""The port to listen on: 8000 when it isn't set, or a whole number from 1 to 65535."""
if "port" not in settings:
text = settings.get("port")
if text is None:
return 8000
text = settings["port"]
if not text.isdigit(): # looks before it leaps: " 8080" and "8080\n" fail here
raise ConfigError(f"port must be a whole number, got {text!r}")
port = int(text)
try:
port = int(text)
except ValueError as err:
raise ConfigError(f"port must be a whole number, got {text!r}") from err
if not 1 <= port <= 65535:
raise ConfigError(f"port must be from 1 to 65535, got {port}")
return port

When Best isn’t best: when bad input is common and the check is cheap and exact, like key in d, checking first reads fine. And for many settings, a library such as pydantic checks them all and lists every problem at once.

Now write it in the page. Press Run tests to check your code. Python runs in your browser, so nothing is sent anywhere. Your code is saved in this browser for next time.

Some tests expect an error: they pass only if your code raises the one named. Try Better from the ladder: it fails the tests with spaces.

Read the port

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: read_port({"port": "8080"}) → 8080
  2. Not run yet: read_port({}) → 8000
  3. Not run yet: read_port({"port": "8O80"}) → raises ConfigError
  4. Not run yet: read_port({"port": "70000"}) → raises ConfigError
  5. Not run yet: read_port({"port": " 8080 "}) → 8080
  6. Not run yet: Extra test
  7. Not run yet: Extra test
  8. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
WayTimeExtra memoryGoodNot so goodUse it when
Okay: catch everythingO(1)O(1)Never crashesHides bad settings and bugs alikeNever with a bare except:
Better: check first, then raiseO(1)O(1)Clear errorsThe check and int() disagree; no causeThe check is cheap and exact, like key in d
Best: try, then raise fromO(1)O(1)Clear errors, with the cause; int() decidesTakes a moment to learnSomething else decides if the input is good
  • Order matters: Python tries each except from the top and uses the first that matches. Put except ConfigError before except ValueError, or the parent catches everything first.
  • One except, several errors: except (KeyError, ValueError): takes a tuple.
  • err is gone after the block: Python deletes the name err when the except block ends, so save what you need inside it.
  • A bare raise re-raises: inside except, raise alone sends the same error on up, traceback and all. Use it after logging, or after the last retry.
  • A return in finally wins: it replaces the return value and throws away an error on its way up. Don’t return from finally.
  • Raising inside except keeps the first error anyway: Python shows it as “During handling of the above exception, another exception occurred”. from err says it’s the cause on purpose, and from None hides it.
  • with cleans up for you: with open(path) as f: closes the file even when an error goes through, like a finally you don’t have to write.

Questions to ask first

  • What should happen to bad input: stop with an error, skip it, or use a default?
  • Who catches the error, and what will they do with it?
  • Should one bad item stop the whole job, or should the rest go on?

A 60-second walkthrough

“A bare except hides every mistake, typos and Ctrl+C included, so I catch only what I expect. I call int() inside a try with one line in it, and catch ValueError. That’s asking forgiveness instead of checking first: int() knows what a number is better than any check I’d write. On ValueError I raise my own ConfigError, made from ValueError, with the bad value in the message. I use raise from, so the original error stays as the cause. The range check goes after the try, so its error isn’t caught by mistake. Callers catch ConfigError where they can tell the user.”

Likely follow-ups

  • Why make your own exception class? Callers can catch your errors by name, and only yours. Making it from ValueError means code that already catches ValueError still works.
  • What’s the difference between except Exception and a bare except? A bare except also catches KeyboardInterrupt and SystemExit, so Ctrl+C and sys.exit() can be caught and thrown away. Both still hide bugs.
  • When does else help? It keeps the try block to the one line that can fail. Code in else runs only if nothing was raised, and its own errors aren’t caught by that except.
  • How do you clean up whether it fails or not? finally, or better, a with block that cleans up for you.

Four problems, from easy to hard. Each has hints, tests you can run here, and its own Okay, Better and Best answers. Try before you open them.

A form sends one number per line, but people type all sorts of things. Write read_numbers(lines), which gives back two lists: the whole numbers it could read, in order, and the line numbers (from 1) it couldn’t. A bad line must not stop the lines after it.

Example: read_numbers(["3", "x", "4"]) gives ([3, 4], [2]).

Hint 1 of 3

A try around the whole loop stops at the first bad line: the loop is over.

Hint 2 of 3

Put the try inside the loop, around the one line that can fail.

Hint 3 of 3

Let int() decide what a number is: it allows spaces, a sign and a newline.

Read the numbers

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: read_numbers(["3", "-2", " 5 "]) → [[3, -2, 5], []]
  2. Not run yet: read_numbers(["3", "x", "4", ""]) → [[3, 4], [2, 4]]
  3. Not run yet: read_numbers([]) → [[], []]
  4. Not run yet: Extra test
  5. Not run yet: Extra test
  6. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

One try around the loop

The idea: Wrap the whole loop in try, and note the bad line in except.

It reads good lines, and notes the first bad one.

def read_numbers(lines: list[str]) -> tuple[list[int], list[int]]:
"""The whole numbers in lines, and the line numbers (from 1) that aren't one."""
numbers: list[int] = []
bad: list[int] = []
try:
for n, line in enumerate(lines, start=1): # noqa: B007 (n is used after the loop)
numbers.append(int(line))
except ValueError:
bad.append(n) # the loop is over: every line after this one is lost
return numbers, bad
Time
O(n)
Space
O(n)
Why
One int() per line, until the first bad one.

Where it breaks: the first bad line ends the loop, so every line after it is lost: ["3", "x", "4"] gives ([3], [2]).

Better

Check each line first

The idea: Strip the line, and if it’s digits after a sign, read it. Otherwise note it as bad.

The waste in Okay: one bad line throws away all the good lines after it, when each line could be judged on its own.

Each line is checked on its own, so the loop goes on after a bad one.

Highlighted lines are new in this rung.

def read_numbers(lines: list[str]) -> tuple[list[int], list[int]]:
"""The whole numbers in lines, and the line numbers (from 1) that aren't one."""
numbers: list[int] = []
bad: list[int] = []
for n, line in enumerate(lines, start=1):
text = line.strip()
if text.lstrip("+-").isdigit(): # looks before it leaps
numbers.append(int(text))
else:
bad.append(n)
return numbers, bad
Time
O(n)
Space
O(n)
Why
One check and at most one int() per line.
What changed from the previous rung
def read_numbers(lines: list[str]) -> tuple[list[int], list[int]]:
"""The whole numbers in lines, and the line numbers (from 1) that aren't one."""
numbers: list[int] = []
bad: list[int] = []
try:
for n, line in enumerate(lines, start=1): # noqa: B007 (n is used after the loop)
numbers.append(int(line))
except ValueError:
bad.append(n) # the loop is over: every line after this one is lost
for n, line in enumerate(lines, start=1):
text = line.strip()
if text.lstrip("+-").isdigit(): # looks before it leaps
numbers.append(int(text))
else:
bad.append(n)
return numbers, bad

Where it breaks: the check isn’t int(). "--4" strips to "4" and passes, then int("--4") raises. "²" is a digit to isdigit(), but not to int(). Either one crashes the whole call.

Best

A try for each line

The idea: Inside the loop, try int(line), and note the line number on ValueError.

The waste in Better: it guesses what int() will accept, and guesses wrong.

int() decides, and the try holds just the one line, so except ValueError can only mean “this line isn’t a number”.

Highlighted lines are new in this rung.

def read_numbers(lines: list[str]) -> tuple[list[int], list[int]]:
"""The whole numbers in lines, and the line numbers (from 1) that aren't one."""
numbers: list[int] = []
bad: list[int] = []
for n, line in enumerate(lines, start=1):
try:
numbers.append(int(line))
except ValueError:
bad.append(n)
return numbers, bad
Time
O(n)
Space
O(n)
Why
One int() per line.
What changed from the previous rung
def read_numbers(lines: list[str]) -> tuple[list[int], list[int]]:
"""The whole numbers in lines, and the line numbers (from 1) that aren't one."""
numbers: list[int] = []
bad: list[int] = []
for n, line in enumerate(lines, start=1):
text = line.strip()
if text.lstrip("+-").isdigit(): # looks before it leaps
numbers.append(int(text))
else:
try:
numbers.append(int(line))
except ValueError:
bad.append(n)
return numbers, bad

A weather service sometimes takes too long, and its client raises TimeoutError. Write call_with_retry(fn, tries=3): call fn() and give back its answer. After a TimeoutError, try again, up to tries calls in all. If the last one times out too, let that TimeoutError go up, message and all. Any other error is a bug: let it go up at once, without trying again. tries below 1 is a mistake: raise ValueError.

Example: if fn times out twice and then gives back 7, call_with_retry(fn) gives back 7 after 3 calls.

Hint 1 of 3

Catch TimeoutError by name. except Exception would also retry a bug three times.

Hint 2 of 3

Count the tries. On the last one, don’t catch the error: send it on up.

Hint 3 of 3

Inside except, a bare raise sends the same error on up, message and all.

Try again after a timeout

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: call_with_retry(lambda: 7) → 7
  2. Not run yet: fn times out twice, then gives 7. The answer, and how many calls it took → [7, 3]
  3. Not run yet: fn always times out: call_with_retry(fn) → raises TimeoutError
  4. Not run yet: fn has a bug that raises KeyError: how many calls → 1
  5. Not run yet: Extra test
  6. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Catch every error, try again

The idea: Loop tries times; return fn() inside try, and pass on any Exception.

It gives back the answer when a later try works.

from collections.abc import Callable
def call_with_retry(fn: Callable[[], object], tries: int = 3) -> object:
"""Call fn, trying again after a TimeoutError, up to tries times in all."""
for _ in range(tries):
try:
return fn()
except Exception: # every error, bugs included
pass
return None
Time
O(t)
Space
O(1)
Why
At most t calls of fn, for t tries.

Where it breaks: it treats a bug like a timeout, and calls it three times. When every try fails, it gives back None, so the caller gets a wrong answer instead of an error.

Better

Catch only TimeoutError, then give up

The idea: Catch TimeoutError by name, and raise RuntimeError("gave up") after the loop.

The waste in Okay: it retries errors that will happen every time, and swallows the last one.

A bug now goes up at once, and running out of tries is an error, not None.

Highlighted lines are new in this rung.

from collections.abc import Callable
def call_with_retry(fn: Callable[[], object], tries: int = 3) -> object:
"""Call fn, trying again after a TimeoutError, up to tries times in all."""
for _ in range(tries):
try:
return fn()
except TimeoutError:
pass
raise RuntimeError("gave up") # the timeout itself, and its message, are lost
Time
O(t)
Space
O(1)
Why
At most t calls of fn, for t tries.
What changed from the previous rung
from collections.abc import Callable
def call_with_retry(fn: Callable[[], object], tries: int = 3) -> object:
"""Call fn, trying again after a TimeoutError, up to tries times in all."""
for _ in range(tries):
try:
return fn()
except Exception: # every error, bugs included
except TimeoutError:
pass
return None
raise RuntimeError("gave up") # the timeout itself, and its message, are lost

Where it breaks: “gave up” is all the caller learns. The timeout itself, and its message, are lost. And tries=0 says “gave up” too, when it was never tried.

Best

Re-raise the last timeout

The idea: Count the tries. On the last one, a bare raise inside except sends that TimeoutError on up.

The waste in Better: it swaps a clear error for a vague one.

A bare raise sends the same error on up, with its message and traceback. The loop only ends without returning or raising when tries is below 1, so that’s where the ValueError goes.

Highlighted lines are new in this rung.

from collections.abc import Callable
def call_with_retry(fn: Callable[[], object], tries: int = 3) -> object:
"""Call fn, trying again after a TimeoutError, up to tries times in all."""
for attempt in range(1, tries + 1):
try:
return fn()
except TimeoutError:
if attempt == tries:
raise # the last timeout goes up as it is, message and all
raise ValueError("tries must be 1 or more")
Time
O(t)
Space
O(1)
Why
At most t calls of fn, for t tries.
What changed from the previous rung
from collections.abc import Callable
def call_with_retry(fn: Callable[[], object], tries: int = 3) -> object:
"""Call fn, trying again after a TimeoutError, up to tries times in all."""
for _ in range(tries):
for attempt in range(1, tries + 1):
try:
return fn()
except TimeoutError:
pass
raise RuntimeError("gave up") # the timeout itself, and its message, are lost
if attempt == tries:
raise # the last timeout goes up as it is, message and all
raise ValueError("tries must be 1 or more")

When Best isn’t best: real retries wait between tries, longer each time, so a busy service can recover. A library such as tenacity does that for you.

Trying again at once can hit the weather service while it’s still busy. Write call_with_backoff(fn, tries=5, first_wait=1, max_wait=30, sleep=time.sleep). Like problem 2, call fn() and give back its answer, and after a TimeoutError, try again, up to tries calls in all. But wait before each new try: first_wait seconds the first time, then double the wait before, but never more than max_wait. Don’t wait after the last try: let its TimeoutError go up. Any other error goes up at once, and tries below 1 raises ValueError.

Wait by calling sleep(seconds), not time.sleep() itself. The tests pass a sleep that writes each wait down instead of waiting, so they run in no time.

Example: if fn times out three times and then gives back 7, call_with_backoff(fn) waits 1, 2 and 4 seconds, and gives back 7.

Hint 1 of 3

Start from problem 2’s answer. Keep the wait in a variable, and call sleep(wait) before each new try.

Hint 2 of 3

After each wait, double it. Check the last try first: when it times out, raise before waiting.

Hint 3 of 3

With 10 tries, doubling from 1 reaches 256 seconds. min(wait * 2, max_wait) keeps it at max_wait at most.

Wait longer each time

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: call_with_backoff(lambda: 7) → 7
  2. Not run yet: fn times out 3 times, then gives 7. The answer, and the waits → [7, [1, 2, 4]]
  3. Not run yet: fn always times out, tries=3. The TimeoutError’s message, and the waits → ['slow 3', [1, 2]]
  4. Not run yet: fn has a bug that raises KeyError. How many calls, and the waits → [1, []]
  5. Not run yet: Extra test
  6. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Wait the same after every timeout

The idea: Problem 2’s loop, with sleep(first_wait) in the except block.

It gives the service a pause, and gives back the answer when a later try works.

import time
from collections.abc import Callable
def call_with_backoff(
fn: Callable[[], object],
tries: int = 5,
first_wait: float = 1,
max_wait: float = 30,
sleep: Callable[[float], object] = time.sleep,
) -> object:
"""Call fn, waiting longer after each TimeoutError, up to tries calls in all."""
for attempt in range(1, tries + 1):
try:
return fn()
except TimeoutError:
sleep(first_wait) # the same wait every time, even after the last try
if attempt == tries:
raise
raise ValueError("tries must be 1 or more")
Time
O(t)
Space
O(1)
Why
At most t calls of fn, and t waits of first_wait seconds.

Where it breaks: the wait never grows, so a service that needs longer to recover gets hit again and again. And it waits after the last try too, when nothing comes next, so the caller hears about the error a second late.

Better

Double the wait each time

The idea: Raise on the last try before waiting. Otherwise sleep(wait), then wait = wait * 2.

The waste in Okay: it waits after the last try, and never gives the service more time.

The waits go 1, 2, 4, 8 seconds: short while the problem is brief, longer while it lasts. This is exponential backoff.

Highlighted lines are new in this rung.

import time
from collections.abc import Callable
def call_with_backoff(
fn: Callable[[], object],
tries: int = 5,
first_wait: float = 1,
max_wait: float = 30,
sleep: Callable[[float], object] = time.sleep,
) -> object:
"""Call fn, waiting longer after each TimeoutError, up to tries calls in all."""
wait = first_wait
for attempt in range(1, tries + 1):
try:
return fn()
except TimeoutError:
if attempt == tries:
raise # no wait after the last try
sleep(wait)
wait = wait * 2 # doubles every time, with no top
raise ValueError("tries must be 1 or more")
Time
O(t)
Space
O(1)
Why
At most t calls of fn. But each wait doubles, so the waits add up to about first_wait × 2^(t−1) seconds.
What changed from the previous rung
import time
from collections.abc import Callable
def call_with_backoff(
fn: Callable[[], object],
tries: int = 5,
first_wait: float = 1,
max_wait: float = 30,
sleep: Callable[[float], object] = time.sleep,
) -> object:
"""Call fn, waiting longer after each TimeoutError, up to tries calls in all."""
wait = first_wait
for attempt in range(1, tries + 1):
try:
return fn()
except TimeoutError:
sleep(first_wait) # the same wait every time, even after the last try
if attempt == tries:
raise
raise # no wait after the last try
sleep(wait)
wait = wait * 2 # doubles every time, with no top
raise ValueError("tries must be 1 or more")

Where it breaks: the wait has no top. With 10 tries, the last wait is 256 seconds, and the waits add up to 511: over 8 minutes before the caller hears anything.

Best

Double it, up to max_wait

The idea: wait = min(wait * 2, max_wait)

The waste in Better: after a few tries, each wait is far longer than any service needs.

min() keeps each wait at max_wait at most. With 10 tries, the waits are 1, 2, 4, 8, 16, then 30 four times: 151 seconds in all.

Highlighted lines are new in this rung.

import time
from collections.abc import Callable
def call_with_backoff(
fn: Callable[[], object],
tries: int = 5,
first_wait: float = 1,
max_wait: float = 30,
sleep: Callable[[float], object] = time.sleep,
) -> object:
"""Call fn, waiting longer after each TimeoutError, up to tries calls in all."""
wait = first_wait
for attempt in range(1, tries + 1):
try:
return fn()
except TimeoutError:
if attempt == tries:
raise # no wait after the last try
sleep(wait)
wait = min(wait * 2, max_wait) # double it, but never past max_wait
raise ValueError("tries must be 1 or more")
Time
O(t)
Space
O(1)
Why
At most t calls of fn, and t − 1 waits of max_wait seconds at most.
What changed from the previous rung
import time
from collections.abc import Callable
def call_with_backoff(
fn: Callable[[], object],
tries: int = 5,
first_wait: float = 1,
max_wait: float = 30,
sleep: Callable[[float], object] = time.sleep,
) -> object:
"""Call fn, waiting longer after each TimeoutError, up to tries calls in all."""
wait = first_wait
for attempt in range(1, tries + 1):
try:
return fn()
except TimeoutError:
if attempt == tries:
raise # no wait after the last try
sleep(wait)
wait = wait * 2 # doubles every time, with no top
wait = min(wait * 2, max_wait) # double it, but never past max_wait
raise ValueError("tries must be 1 or more")

When Best isn’t best: when many clients time out together, they all try again at the same moments and overload the service again. Real code adds jitter: sleep(random.uniform(0, wait)), so the tries spread out. And when the service says how long to wait (a Retry-After header), wait that long instead. Libraries such as tenacity do all of this.

A shop takes a list of orders together. Write apply_all(stock, orders), which takes each (item, qty) order out of the stock dict. If any order wants more than is left, raise OutOfStock naming the item, and leave stock exactly as it was. An item that isn’t in stock is a mistake: let the KeyError go up, and change nothing then too.

Example: with stock = {"pen": 5, "cup": 2}, apply_all(stock, [("pen", 2), ("cup", 1)]) leaves {"pen": 3, "cup": 1}. apply_all(stock, [("pen", 2), ("cup", 3)]) raises OutOfStock, and stock doesn’t change.

Hint 1 of 3

If you change the stock as you go, a failing order leaves the earlier ones taken.

Hint 2 of 3

Check everything before you change anything.

Hint 3 of 3

Two orders for the same item can each fit alone, but not together. Add them up first.

All the orders, or none

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: stock = {"pen": 5, "cup": 2}; apply_all(stock, [("pen", 2), ("cup", 1)]); stock → {'pen': 3, 'cup': 1}
  2. Not run yet: apply_all({"pen": 5, "cup": 2}, [("pen", 9)]) → raises OutOfStock
  3. Not run yet: stock after apply_all(stock, [("pen", 2), ("cup", 3)]) raises OutOfStock → {'pen': 5, 'cup': 2}
  4. Not run yet: Extra test
  5. Not run yet: Extra test
  6. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
Show the Okay, Better and Best answers
Okay

Check and take, one order at a time

The idea: For each order, raise OutOfStock if it doesn’t fit, otherwise take it.

It raises OutOfStock for an order that doesn’t fit, with the item named.

class OutOfStock(Exception):
"""The orders want more of an item than the stock has."""
def apply_all(stock: dict[str, int], orders: list[tuple[str, int]]) -> None:
"""Take every order out of stock, or, if any can't be filled, none of them."""
for item, qty in orders:
if stock[item] < qty: # an unknown item stops here with KeyError
raise OutOfStock(f"{item}: want {qty}, have {stock[item]}")
stock[item] -= qty # the orders before a failing one stay taken
Time
O(n)
Space
O(1)
Why
One check and one change per order.

Where it breaks: the orders before the failing one are already taken. After [("pen", 2), ("cup", 3)] fails, stock has 3 pens, not 5.

Better

Check every order, then take them

The idea: Loop once to check each order, raising OutOfStock if one doesn’t fit. Loop again to take them.

The waste in Okay: it changes the stock before it knows the whole job can work.

Nothing changes until every order has passed, so an error leaves the stock as it was.

Highlighted lines are new in this rung.

class OutOfStock(Exception):
"""The orders want more of an item than the stock has."""
def apply_all(stock: dict[str, int], orders: list[tuple[str, int]]) -> None:
"""Take every order out of stock, or, if any can't be filled, none of them."""
for item, qty in orders: # check every order first...
if stock[item] < qty:
raise OutOfStock(f"{item}: want {qty}, have {stock[item]}")
for item, qty in orders: # ...then change the stock
stock[item] -= qty
Time
O(n)
Space
O(1)
Why
Two passes over the orders.
What changed from the previous rung
class OutOfStock(Exception):
"""The orders want more of an item than the stock has."""
def apply_all(stock: dict[str, int], orders: list[tuple[str, int]]) -> None:
"""Take every order out of stock, or, if any can't be filled, none of them."""
for item, qty in orders:
if stock[item] < qty: # an unknown item stops here with KeyError
for item, qty in orders: # check every order first...
if stock[item] < qty:
raise OutOfStock(f"{item}: want {qty}, have {stock[item]}")
stock[item] -= qty # the orders before a failing one stay taken
for item, qty in orders: # ...then change the stock
stock[item] -= qty

Where it breaks: it checks each order alone. Two orders of 3 pens each fit 5 pens one at a time, so both are taken and the stock goes to -1.

Best

Add up each item, check, then take

The idea: Total the quantity per item, check each total against the stock, then take the totals.

The waste in Better: it checks orders the stock can’t fill together, one at a time.

Every check happens before any change, so whatever raises, OutOfStock or KeyError, the stock is untouched.

Highlighted lines are new in this rung.

class OutOfStock(Exception):
"""The orders want more of an item than the stock has."""
def apply_all(stock: dict[str, int], orders: list[tuple[str, int]]) -> None:
"""Take every order out of stock, or, if any can't be filled, none of them."""
wanted: dict[str, int] = {}
for item, qty in orders:
wanted[item] = wanted.get(item, 0) + qty
for item, qty in wanted.items(): # check the totals before changing anything
if stock[item] < qty:
raise OutOfStock(f"{item}: want {qty}, have {stock[item]}")
for item, qty in wanted.items():
stock[item] -= qty
Time
O(n)
Space
O(k), k = items
Why
One pass to add up, then one check and one change per item.
What changed from the previous rung
class OutOfStock(Exception):
"""The orders want more of an item than the stock has."""
def apply_all(stock: dict[str, int], orders: list[tuple[str, int]]) -> None:
"""Take every order out of stock, or, if any can't be filled, none of them."""
for item, qty in orders: # check every order first...
wanted: dict[str, int] = {}
for item, qty in orders:
wanted[item] = wanted.get(item, 0) + qty
for item, qty in wanted.items(): # check the totals before changing anything
if stock[item] < qty:
raise OutOfStock(f"{item}: want {qty}, have {stock[item]}")
for item, qty in orders: # ...then change the stock
for item, qty in wanted.items():
stock[item] -= qty

When Best isn’t best: with a database, a transaction does this for you: it undoes every change if any step fails.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Predict the outputWhat does this print?
    def inner():
        return int("x")
    
    def outer():
        try:
            return inner()
        except KeyError:
            return "key"
    
    try:
        print(outer())
    except ValueError:
        print("value")
  2. Predict the outputWhat does this print?
    def check(text):
        try:
            n = int(text)
        except ValueError:
            return "bad"
        else:
            return f"ok {n}"
        finally:
            print("done", end=" ")
    
    print(check("x"))
  3. Spot the bugread_port raises ConfigError, which is made from ValueError. Why does "Bad config" never print?
    try:
        read_port(settings)
    except ValueError:
        print("Not a number")
    except ConfigError:
        print("Bad config")
  4. Multiple choiceWhy is a bare except: worse than except ValueError:?
  5. Multiple choiceInside except ValueError as err, which line raises a ConfigError and keeps the ValueError as its cause?

Add these to your review deck. Each card comes back a little later every time you get it right.

  • An error goes up through the calls until a try with a matching except catches it.
  • Catch errors by name. Never a bare except:; rarely except Exception.
  • Keep the try block to the line that can fail. Put the rest in else.
  • finally runs every time. A with block cleans up for you.
  • Make your own error: class ConfigError(ValueError). Callers can catch it by name.
  • raise ConfigError(...) from err keeps the cause. A bare raise sends the same error on up.
  • Try it and catch the error, rather than guess with a check, when something else decides what’s valid.

Can you do these now?

If you’re not sure of any of these, scroll back to that part before moving on.

Status: Not started