My server ignored the port I set
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'See the problem
Section titled “See the problem”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 8000A 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?
Define the task
Section titled “Define the task”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, asint()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'.
Pause and think
Section titled “Pause and think”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.
Understand the idea
Section titled “Understand the idea”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.
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)
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 failexcept 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.
Climb the ladder
Section titled “Climb the ladder”All three give back 8080 for "8080" and 8000 when the port isn’t set. What changes is what happens to a bad port.
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.
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 portWhere 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.
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 portWhen 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.
Write it yourself
Section titled “Write it yourself”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- Not run yet:
read_port({"port": "8080"})→8080 - Not run yet:
read_port({})→8000 - Not run yet:
read_port({"port": "8O80"})→ raisesConfigError - Not run yet:
read_port({"port": "70000"})→ raisesConfigError - Not run yet:
read_port({"port": " 8080 "})→8080 - Not run yet: Extra test
- Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Compare
Section titled “Compare”| Way | Time | Extra memory | Good | Not so good | Use it when |
|---|---|---|---|---|---|
| Okay: catch everything | O(1) | O(1) | Never crashes | Hides bad settings and bugs alike | Never with a bare except: |
| Better: check first, then raise | O(1) | O(1) | Clear errors | The check and int() disagree; no cause | The check is cheap and exact, like key in d |
| Best: try, then raise from | O(1) | O(1) | Clear errors, with the cause; int() decides | Takes a moment to learn | Something else decides if the input is good |
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”- Order matters: Python tries each
exceptfrom the top and uses the first that matches. Putexcept ConfigErrorbeforeexcept ValueError, or the parent catches everything first. - One
except, several errors:except (KeyError, ValueError):takes a tuple. erris gone after the block: Python deletes the nameerrwhen theexceptblock ends, so save what you need inside it.- A bare
raisere-raises: insideexcept,raisealone sends the same error on up, traceback and all. Use it after logging, or after the last retry. - A
returninfinallywins: it replaces the return value and throws away an error on its way up. Don’t return fromfinally. - Raising inside
exceptkeeps the first error anyway: Python shows it as “During handling of the above exception, another exception occurred”.from errsays it’s the cause on purpose, andfrom Nonehides it. withcleans up for you:with open(path) as f:closes the file even when an error goes through, like afinallyyou don’t have to write.
Say it in the interview
Section titled “Say it in the interview”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
ValueErrormeans code that already catchesValueErrorstill works. - What’s the difference between
except Exceptionand a bareexcept? A bareexceptalso catchesKeyboardInterruptandSystemExit, so Ctrl+C andsys.exit()can be caught and thrown away. Both still hide bugs. - When does
elsehelp? It keeps thetryblock to the one line that can fail. Code inelseruns only if nothing was raised, and its own errors aren’t caught by thatexcept. - How do you clean up whether it fails or not?
finally, or better, awithblock that cleans up for you.
Practice
Section titled “Practice”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.
1. Read the numbers (easy)
Section titled “1. Read the numbers (easy)”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- Not run yet:
read_numbers(["3", "-2", " 5 "])→[[3, -2, 5], []] - Not run yet:
read_numbers(["3", "x", "4", ""])→[[3, 4], [2, 4]] - Not run yet:
read_numbers([])→[[], []] - Not run yet: Extra test
- Not run yet: Extra test
- 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
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]).
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, badWhere 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.
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, bad2. Try again after a timeout (medium)
Section titled “2. Try again after a timeout (medium)”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- Not run yet:
call_with_retry(lambda: 7)→7 - Not run yet:
fn times out twice, then gives 7. The answer, and how many calls it took→[7, 3] - Not run yet:
fn always times out: call_with_retry(fn)→ raisesTimeoutError - Not run yet:
fn has a bug that raises KeyError: how many calls→1 - Not run yet: Extra test
- 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
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.
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 lostWhere 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.
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.
3. Wait longer each time (medium)
Section titled “3. Wait longer each time (medium)”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- Not run yet:
call_with_backoff(lambda: 7)→7 - Not run yet:
fn times out 3 times, then gives 7. The answer, and the waits→[7, [1, 2, 4]] - Not run yet:
fn always times out, tries=3. The TimeoutError’s message, and the waits→['slow 3', [1, 2]] - Not run yet:
fn has a bug that raises KeyError. How many calls, and the waits→[1, []] - Not run yet: Extra test
- 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
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 timefrom 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.
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 timefrom 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 timefrom 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.
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 timefrom 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 timefrom 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.
4. All the orders, or none (hard)
Section titled “4. All the orders, or none (hard)”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- Not run yet:
stock = {"pen": 5, "cup": 2}; apply_all(stock, [("pen", 2), ("cup", 1)]); stock→{'pen': 3, 'cup': 1} - Not run yet:
apply_all({"pen": 5, "cup": 2}, [("pen", 9)])→ raisesOutOfStock - Not run yet:
stock after apply_all(stock, [("pen", 2), ("cup", 3)]) raises OutOfStock→{'pen': 5, 'cup': 2} - Not run yet: Extra test
- Not run yet: Extra test
- 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
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.
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] -= qtyWhere 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.
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] -= qtyWhen Best isn’t best: with a database, a transaction does this for you: it undoes every change if any step fails.
Check yourself
Section titled “Check yourself”A few quick questions. Ones you miss are remembered, so you can find your weak spots later.
Flashcards
Section titled “Flashcards”Add these to your review deck. Each card comes back a little later every time you get it right.
Cheat sheet
Section titled “Cheat sheet”- An error goes up through the calls until a
trywith a matchingexceptcatches it. - Catch errors by name. Never a bare
except:; rarelyexcept Exception. - Keep the
tryblock to the line that can fail. Put the rest inelse. finallyruns every time. Awithblock cleans up for you.- Make your own error:
class ConfigError(ValueError). Callers can catch it by name. raise ConfigError(...) from errkeeps the cause. A bareraisesends the same error on up.- Try it and catch the error, rather than guess with a check, when something else decides what’s valid.
Go deeper
Section titled “Go deeper”- In the Python tutorial: errors and exceptions.
- In the Python reference: the try statement, the raise statement and the built-in exceptions, with their family tree.
- From the Python glossary: EAFP and LBYL.
- Draw your own: the call stack on the Python visualizer.
- Next on the Python roadmap: My script can’t find its file when I run it from another folder, where
withandFileNotFoundErrorcome up all the time.
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