Chapter 3 · Writing Real Programs
Errors and Exceptions
- Page 11 of 20
- 4 min read
Things go wrong: a file is missing, a user types letters where you expected a number, an API times out, a model returns broken JSON. In Python these are exceptions. An unhandled exception stops your program with a traceback. Handling them well is what separates a demo from a program people can rely on.
Errors you will meet
| Exception | Typical cause |
|---|---|
SyntaxError | The code is not valid Python: a missing colon, bracket or quote |
IndentationError | Inconsistent indentation |
NameError | Using a name that does not exist (often a typo) |
TypeError | An operation on the wrong type: "5" + 5 |
ValueError | Right type, bad value: int("abc") |
KeyError / IndexError | A missing dictionary key / a list position that does not exist |
FileNotFoundError | Opening a file that is not there |
ZeroDivisionError | Dividing by zero |
Catching an exception: try / except
Put the risky code in a try block. If a matching exception happens, Python jumps to the except block instead of crashing:
def parse_tokens(text):
try:
return int(text)
except ValueError:
print(f"Not a number: {text!r}")
return 0
print(parse_tokens("250"))
print(parse_tokens("two hundred"))250
Not a number: 'two hundred'
0Always catch the specific exception you expect (ValueError here). Then a different, unexpected problem — a real bug — still shows up loudly instead of being silently swallowed.
The full form: else and finally
Language models are asked to reply in JSON all the time, and sometimes they don't quite manage it. Handling that gracefully looks like this:
import json
raw = '{"answer": "Dhaka", "confidence": 0.93' # the closing brace is missing
try:
data = json.loads(raw)
except json.JSONDecodeError as error:
print("The model did not return valid JSON:", error)
data = None
else:
print("Parsed:", data) # runs only if nothing went wrong
finally:
print("Finished parsing") # always runsThe model did not return valid JSON: Expecting ',' delimiter: line 1 column 39 (char 38)
Finished parsingexcept … as errorgives you the exception object; its message says exactly what was wrong and where.elseruns only when thetryblock succeeded.finallyruns no matter what — for clean-up such as closing a connection.
Raising your own exceptions
When your function receives something it cannot work with, raise an exception with a clear message. Failing early and clearly is far better than continuing with bad data:
def set_temperature(value):
if not 0 <= value <= 2:
raise ValueError(f"temperature must be between 0 and 2, got {value}")
return value
print(set_temperature(0.7))
set_temperature(5)0.7
Traceback (most recent call last):
File "main.py", line 7, in <module>
set_temperature(5)
File "main.py", line 3, in set_temperature
raise ValueError(f"temperature must be between 0 and 2, got {value}")
ValueError: temperature must be between 0 and 2, got 5A real pattern: retrying with exponential backoff
AI APIs sometimes reject a call because you are sending too many (a rate limit, HTTP 429) or because the service is busy. The professional response is to wait and try again, waiting longer each time — exponential backoff. You can define your own exception type by inheriting from Exception (more on inheritance on the next page):
import random
import time
class TemporaryAPIError(Exception):
"""A failure worth retrying, like a timeout or a rate limit."""
random.seed(3)
def flaky_api_call():
if random.random() < 0.6:
raise TemporaryAPIError("rate limited")
return "Here is your answer."
def call_with_retries(max_attempts=5):
for attempt in range(1, max_attempts + 1):
try:
return flaky_api_call()
except TemporaryAPIError as error:
wait = 0.01 * 2 ** attempt # 0.02, 0.04, 0.08 ... seconds
print(f"Attempt {attempt} failed ({error}); waiting {wait:.2f}s")
time.sleep(wait)
raise RuntimeError("API still failing after retries")
print(call_with_retries())Attempt 1 failed (rate limited); waiting 0.02s
Attempt 2 failed (rate limited); waiting 0.04s
Attempt 3 failed (rate limited); waiting 0.08s
Here is your answer.Real SDKs already retry a few times for you (the OpenAI and Anthropic SDKs retry twice by default), but you will write this loop yourself around anything else that can fail temporarily. Never retry errors that will not fix themselves — a wrong API key (401) fails the same way every time.
An anti-pattern: catching everything
try:
result = 10 / 0
except Exception: # too broad: hides what went wrong
result = None
print(result)NoneThe program "works", but you have no idea that it tried to divide by zero. A bare except: or except Exception: with no logging hides bugs. If you really must catch everything (for example, at the very top of a long-running program), at least print or log the error.
Try it yourself
- Ask the user for their age until they type a valid whole number.
- Open a file whose name the user types; print a friendly message if it does not exist.
- Write
safe_divide(a, b)that raises aValueErrorwith a clear message whenbis 0.