Lesson 8 of 8
Time to combine everything: fetch rates from a public API, cache them, validate input, handle every failure mode, and convert.
The real version
This is the code to run on your own machine. It uses exchangerate.host, which needs no API key.
import json
import os
import time
import requests
API_URL = "https://api.exchangerate.host/latest"
CACHE_FILE = "rates.json"
CACHE_MAX_AGE = 3600
class ConversionError(Exception):
"""Raised when a conversion cannot be completed."""
def cache_is_fresh(path=CACHE_FILE, max_age=CACHE_MAX_AGE):
if not os.path.exists(path):
return False
return (time.time() - os.path.getmtime(path)) < max_age
def fetch_rates(base="USD"):
"""Fetch current rates, raising ConversionError on any failure."""
try:
response = requests.get(API_URL, params={"base": base}, timeout=10)
response.raise_for_status()
data = response.json()
except requests.exceptions.Timeout:
raise ConversionError("The rate service timed out")
except requests.exceptions.ConnectionError:
raise ConversionError("Could not reach the rate service")
except requests.exceptions.HTTPError as e:
raise ConversionError(f"Rate service error: {e.response.status_code}")
except json.JSONDecodeError:
raise ConversionError("Rate service returned invalid data")
if "rates" not in data:
raise ConversionError("Unexpected response shape")
return data["rates"]
def get_rates(base="USD"):
"""Return rates from cache when fresh, otherwise fetch and cache."""
if cache_is_fresh():
with open(CACHE_FILE) as f:
return json.load(f)
rates = fetch_rates(base)
tmp = CACHE_FILE + ".tmp"
with open(tmp, "w") as f:
json.dump(rates, f)
os.replace(tmp, CACHE_FILE)
return rates
def convert(amount, to_currency, rates):
"""Convert amount into to_currency using rates."""
if to_currency not in rates:
raise ConversionError(f"Unknown currency: {to_currency}")
return amount * rates[to_currency]
def main():
try:
rates = get_rates()
except ConversionError as e:
print(f"Could not load rates: {e}")
return
while True:
raw = input("\nAmount in USD (or 'quit'): ")
if raw == "quit":
break
try:
amount = float(raw)
except ValueError:
print("That is not a number.")
continue
if amount <= 0:
print("Amount must be positive.")
continue
currency = input("Convert to (e.g. EUR): ").upper()
try:
result = convert(amount, currency, rates)
except ConversionError as e:
print(e)
else:
print(f"{amount:.2f} USD = {result:.2f} {currency}")
if __name__ == "__main__":
main()
Every piece is something you've learned: a custom exception (6.8), try/except/else (6.6),
atomic writes and caching (8.7), specific network exceptions (8.5), and the
if __name__ == "__main__" guard (7.2).
Note that every requests failure is translated into one ConversionError. The menu loop
doesn't need to know anything about HTTP — that's the boundary between layers doing its job.
Try it yourself
Same logic, with the network call simulated so it runs here.
Take it further
- Support any base currency, not just USD, with
params={"base": base}. - Add a
--listoption that prints available currency codes. - Record every conversion to a history file with
append_recordfrom lesson 8.7. - Add the retry-with-backoff logic from lesson 8.5 around
fetch_rates. - Read an API key from
os.environif you switch to a service that needs one.
A different project
If a weather app appeals more, Open-Meteo is free and needs no key. The structure is identical: fetch, validate, cache, handle errors, present.
Module 8 complete
You can now make GET and POST requests, parse and reshape JSON, send parameters and headers, keep API keys out of your source code, handle timeouts and connection failures, scrape HTML when there's no API, and build a caching pipeline that writes safely to disk.
Your programs can talk to the rest of the internet.
Next up: Module 9 — Intermediate Python & Best Practices, where you'll learn generators, decorators, regular expressions, and how to test everything you've built.
Questions about this lesson
Log in to ask a question.