When the Network Fails
The network is the one part of a program that you do not control. The service can be slow, down for maintenance, or answer with an error, and the Wi-Fi can simply drop. A script that assumes the network always works will fail at the worst possible moment. This page makes the exchange-rate script survive: a time limit, a check of the status code, and a fallback to the last rate it saved, clearly labelled as such.
Think of phoning a shop to check a price. When nobody answers, you do not give up on the whole errand. You use the price on last week's receipt, and you remember that it is last week's price.
Timeouts
Without a timeout, a request can wait forever if the service accepts the connection and then goes quiet. The program freezes, and the only way out is Control and C. With timeout=5, requests gives up after five seconds and raises an exception instead, which the program can handle. (Strictly, the limit applies to each wait, connecting and each pause while the answer arrives, rather than to the whole request; for a small answer like this one the difference does not matter.) Every request in this book has a timeout, and every request you write should too.
Bad Status Codes
A service that answers with an error, such as 404 for an address that does not exist or 422 for a request it cannot process, still counts as an answer: requests.get does not raise anything. response.raise_for_status() turns error codes, the 400s and 500s, into an exception, so a bad answer cannot slip through as if it were data. Asking this service for a currency that does not exist shows the difference.
>>> import requests
>>> r = requests.get("https://api.frankfurter.dev/v2/rates", params={"base": "EUR", "quotes": "XYZ"}, timeout=5)
>>> r.status_code
422
>>> r.raise_for_status()
Traceback (most recent call last):
File "<python-input-3>", line 1, in <module>
r.raise_for_status()
~~~~~~~~~~~~~~~~~~^^
...
requests.exceptions.HTTPError: 422 Client Error: Unprocessable Entity for url: https://api.frankfurter.dev/v2/rates?base=EUR"es=XYZ
Catching Network Errors Precisely
requests has its own family of exceptions. A timeout, a refused connection, a missing network and a bad status code all belong to one parent, requests.RequestException. Catching that one name catches every way the request itself can fail. One more failure is worth expecting: an answer in a shape the program did not expect, such as an empty list or a missing rate, which shows up as an IndexError or a KeyError when the program reads it. An except can name several kinds of error at once, in brackets separated by commas, and the script names exactly these three and nothing else, which is the same rule as Chapter 8: catch the errors you expect, and let real bugs show.
Falling Back to the Last Saved Answer
The version below puts it together. fetch_rate asks the service, raises on a bad status, saves the rate and its date to rates.json, and returns them. saved_rate reads that file back, or returns None if there is none yet. main tries the service first. If any request exception happens, it says so, and uses the saved rate instead, and the final line always says which rate was used and from which day. If there is no saved rate either, the program stops with a clear message and a non-zero exit code, which is how a program tells the terminal, and any other program running it, that it failed. sys.exit with a message does both.
import json import sys from decimal import Decimal from pathlib import Path import requests URL = "https://api.frankfurter.dev/v2/rates" SAVED = Path("rates.json") def fetch_rate(): """Ask the service for today's EUR to USD rate, save it, and return it.""" response = requests.get(URL, params={"base": "EUR", "quotes": "USD"}, timeout=5) response.raise_for_status() latest = response.json()[0] saved = {"date": latest["date"], "rate": str(latest["rate"])} SAVED.write_text(json.dumps(saved), encoding="utf-8") return saved["date"], Decimal(saved["rate"]) def saved_rate(): """Return the last saved date and rate, or None if nothing was saved.""" if not SAVED.exists(): return None saved = json.loads(SAVED.read_text(encoding="utf-8")) return saved["date"], Decimal(saved["rate"]) def main(): try: date, rate = fetch_rate() source = f"today's rate, {date}" except (requests.RequestException, KeyError, IndexError) as error: print(f"Could not get today's rate: {type(error).__name__}") last = saved_rate() if last is None: sys.exit("No saved rate to fall back on. Try again when online.") date, rate = last source = f"the rate saved on {date}" spent = Decimal("86.40") print(f"EUR {spent} is about USD {spent * rate:.2f}, using {source}") if __name__ == "__main__": main()
EUR 86.40 is about USD 98.95, using today's rate, 2026-09-23
In the message, type(error) finds the kind of exception that happened, and __name__ gives that kind's name as text, such as ConnectionError. It makes the message specific without showing a whole traceback to the person using the program.
Testing the Failure on Purpose
A fallback that has never run is a fallback you cannot trust. Test it: switch off your Wi-Fi and run the script again. With a rate saved from an earlier run, it reports the connection problem and carries on with the saved rate, and says how old it is. Delete rates.json and run it once more with the Wi-Fi still off, and it stops with the message and an exit code of 1.
Could not get today's rate: ConnectionError EUR 86.40 is about USD 99.04, using the rate saved on 2026-09-22
Could not get today's rate: ConnectionError No saved rate to fall back on. Try again when online.
- "Requests have a sensible default timeout." Without one, requests can wait indefinitely. Always pass timeout.
- "A 404 or 422 raises an exception by itself." Only after raise_for_status(). Without it, the error answer looks like any other response.
- "Falling back silently is fine." The person needs to know the rate is not today's. Say where the number came from.
- "If it worked once, the fallback works." Test the failure on purpose, with the network off, before relying on it.
- Honest, graceful failure is what separates a script you run once from a tool you can rely on every morning.
- Timeouts, status checks and fallbacks are the first steps of the reliability work at the heart of Backend Deep Dive.
Knowledge Check
What does timeout=5 do in requests.get?
- Gives up after five seconds and raises an exception
- Tries the request five times over before giving up on it
- Waits five seconds before sending the request
- Saves the answer for five minutes so later runs can reuse it
What does response.raise_for_status() do with a 422 answer?
- Nothing, because the service did send an answer
- Retries the same request again and again until a 200 comes back
- Raises an HTTPError, so the error cannot pass as data
- Returns the number 422 as the result of the function call
Why does the script catch requests.RequestException?
- Because it catches every error that could happen anywhere in the program
- Because Python requires it before any network call
- Because it prevents the service from returning an error
- It covers every way the request can fail, and nothing else
What should the script tell the user when it falls back to a saved rate?
- Nothing, so the report looks exactly the same as usual
- Only that an error happened, with the full traceback
- That it used a saved rate, and the date of that rate
- That it guessed a rate close to yesterday's value
You got correct