Project: a price-watch alert bot
An API client, alert rules, digest emails and a state file, so reruns never repeat.
Create a free account Try part of it here
Why it matters
Bots that watch something and tell people when it changes are everywhere: price drops, stock alerts, build failures, server health. They look simple, but the real work is in the edges. What happens when the API is down for one product? When the job runs twice? When ten alerts fire for the same person? This project builds one that handles all three, the way a production job must.
What you'll be able to do
- Wrap an API in a small client class that turns error statuses into clear exceptions
- Keep state in a JSON file so a rerun never sends the same alert twice
- Group many alerts into one digest email per person
- Keep going when one product fails, and report what went wrong at the end
What it covers
- The brief
- Step 1: a client that speaks in exceptions
- Exceptions instead of status codes
- Step 2: state that survives a rerun
- Step 3: one digest per person
- Step 4: wiring it together
- Worked example: the rules, without sending anything
- Running it every evening
The brief
Trail Club members can watch products and set a target price. Every evening a job checks the practice API's prices. Anyone whose watched product has reached their target gets one email listing all of their alerts. The job must:
- send at most one digest per member per run, and never repeat an alert it already sent;
- keep working when a product's price can't be fetched, and report which ones failed;
- tell members when a product they watch is discontinued, and name its replacement;
- fetch each product's prices once per run, even if several members watch it.
The data comes from two routes. GET /v1/watchlists lists the members and what they watch. GET /v1/prices/<sku> gives a product's daily prices for June 2025:
import requests watch = requests.get("https://api.gradient.test/v1/watchlists").json()["data"] for member in watch: print(member["id"], member["name"], [(w["sku"], w["target_price"]) for w in member["watching"]]) reply = requests.get("https://api.gradient.test/v1/prices/TE-107", params={"days": 3}) print(reply.status_code, reply.json()["prices"]) for sku in ["AP-127", "CA-129"]: r = requests.get(f"https://api.gradient.test/v1/prices/{sku}") print(sku, r.status_code, r.json()["error"])
Three kinds of answer come back: 200 with prices, 410 "Gone" for a discontinued product (with its replacement), and 503 "Service Unavailable" for a product whose price service is down.
Each box is one small piece of code with one job. That's what makes the bot easy to test and safe to rerun.
Step 1: a client that speaks in exceptions
Hide the HTTP details in one small class. Callers ask for a product's latest price and get a number, or a clear exception that says what went wrong:
import requests API = "https://api.gradient.test/v1" class Discontinued(Exception): def __init__(self, sku, replaced_by): super().__init__(f"{sku} was discontinued; its replacement is {replaced_by}") self.sku, self.replaced_by = sku, replaced_by class Unavailable(Exception): pass class PriceClient: def __init__(self): self.cache = {} def latest(self, sku): if sku not in self.cache: reply = requests.get(f"{API}/prices/{sku}", params={"days": 1}) if reply.status_code == 410: raise Discontinued(sku, reply.json()["error"]["replaced_by"]) if reply.status_code >= 500: raise Unavailable(f"{sku}: {reply.json()['error']['message']}") reply.raise_for_status() self.cache[sku] = reply.json()["prices"][-1] return self.cache[sku]
days=1 asks for only the latest day. The cache dictionary means two members watching TE-107 cause one request, not two. raise_for_status() turns any other error status, like 404, into an exception too, so nothing fails silently. Code that uses the client never touches status codes: it just catches Discontinued and Unavailable.
Exceptions instead of status codes
Using the client classes above (already in the starter), set result to a dictionary mapping each SKU in skus to its latest price, "discontinued -> <replacement>", or "unavailable". One bad product must not stop the loop.
import requests API = "https://api.gradient.test/v1" class Discontinued(Exception): def __init__(self, sku, replaced_by): super().__init__(f"{sku} was discontinued; its replacement is {replaced_by}") self.sku, self.replaced_by = sku, replaced_by class Unavailable(Exception): pass class PriceClient: def __init__(self): self.cache = {} def latest(self, sku): if sku not in self.cache: reply = requests.get(f"{API}/prices/{sku}", params={"days": 1}) if reply.status_code == 410: raise Discontinued(sku, reply.json()["error"]["replaced_by"]) if reply.status_code >= 500: raise Unavailable(f"{sku}: {reply.json()['error']['message']}") reply.raise_for_status() self.cache[sku] = reply.json()["prices"][-1] return self.cache[sku] skus = ["TE-107", "AP-127", "CA-129", "PA-108"] client = PriceClient() result = {}
Step 2: state that survives a rerun
Jobs get rerun: a scheduler retries after a crash, or someone runs it by hand to check. A rerun must not email everyone again. The fix is to remember what was sent. Each alert gets a key, and the keys go in a JSON file:
import json from pathlib import Path def load_sent(path): p = Path(path) return set(json.loads(p.read_text())) if p.exists() else set() def save_sent(path, sent): Path(path).write_text(json.dumps(sorted(sent), indent=2)) key = f"{member_id}:{sku}:{price_date}" # e.g. "w1:TE-107:2025-06-30"
A set makes "already sent?" a quick key in sent check. JSON has no sets, so it's saved as a sorted list and turned back into a set when loaded. The key includes the price's date. If the price falls to the target again on a later day, that's news worth a new alert, so it gets a new key.
Save the state after the emails go out. If the job crashes halfway, the worst case is a resend of the alerts that didn't get recorded, never a silent loss. Keys that aren't recorded mean "try again".
import json sent = {"w1:TE-107:2025-06-30", "w3:TE-107:2025-06-30"} text = json.dumps(sorted(sent)) again = set(json.loads(text)) print(again == sent, "w1:TE-107:2025-06-30" in again, len(text) > 0)
Show the answer
True True True
json.dumps can't save a set, but a sorted list works; set(json.loads(...)) turns it back into the same set.
Step 3: one digest per person
Ten alerts for one person should be one email, not ten. Collect the alerts in a dictionary keyed by member, then send each member one message with a line per alert:
from email.message import EmailMessage def digest(member, lines): msg = EmailMessage() msg["From"] = "alerts@shop.example.com" msg["To"] = member["email"] msg["Subject"] = f"Price alerts: {len(lines)} update{'s' if len(lines) != 1 else ''}" first = member["name"].split()[0] msg.set_content(f"Hi {first},\n\n" + "\n".join(f"- {line}" for line in lines) + "\n\nThe shop team") return msg
Lines read like "TE-107 is now $356.25 (your target: $400.00)". A discontinued product gets its own line, "AP-127 is discontinued; try AP-138 instead", but only once: it goes into the state file like any alert, with "gone" in place of the date. All the digests then go out over one SMTP connection, as in the email lesson.
Step 4: wiring it together
The run function is the only place that knows about all three pieces. Its shape:
- Load the sent keys, create the client, fetch the watchlists.
- Loop over members and their watched products. Inside the loop, a
tryaroundclient.latestwith oneexceptper exception keeps each product's failure local. Anelseblock runs only when no exception happened, which is the natural home for the price rule. - Collect each member's new lines; make one digest for each member who has any.
- Send all digests over one connection, but only if there's at least one, so a quiet evening never logs in to the mail server at all.
- Save the keys and return a summary.
Returning a summary dictionary, rather than printing, makes the function easy to test and easy to log. The checks below call it twice in a row and confirm the second call does nothing new: the most important property of any scheduled job.
Worked example: the rules, without sending anything
Before wiring up email, run the rules on their own and print what would be sent. A dry run like this is the safest way to build and check a bot.
import requests from collections import defaultdict API = "https://api.gradient.test/v1" members = requests.get(f"{API}/watchlists").json()["data"] latest, problems = {}, {} for sku in sorted({w["sku"] for m in members for w in m["watching"]}): reply = requests.get(f"{API}/prices/{sku}", params={"days": 1}) if reply.ok: latest[sku] = reply.json()["prices"][-1] else: problems[sku] = (reply.status_code, reply.json()["error"].get("replaced_by")) would_send = defaultdict(list) for m in members: for w in m["watching"]: p = latest.get(w["sku"]) if p and p["price"] <= w["target_price"]: would_send[m["id"]].append(f"{w['sku']} is now ${p['price']:.2f} (your target: ${w['target_price']:.2f})") for member_id, lines in would_send.items(): print(member_id, lines) print("problems:", problems) print(len(latest) + len(problems), "products fetched, once each")
The set comprehension {w["sku"] for ...} collects each product once, so TE-107 is fetched once though two members watch it. Successful replies go in latest; failures are recorded in problems instead of stopping the loop. defaultdict(list) starts each member with an empty list of lines. The dry run shows three members with alerts, plus the two problem products. That's exactly what the real run should email and report, so it's a ready-made test.
Running it every evening
A bot like this runs on a schedule, on a computer that stays on. Common choices:
- cron on a Linux server: a line like
0 19 * * * /srv/bot/.venv/bin/python /srv/bot/run.pyruns it at 19:00 every day. Write down which time zone the server uses, or the bot will run at the wrong hour. - GitHub Actions with a
scheduletrigger, which needs no server of your own. - A cloud scheduler, such as a cron job on the platform that hosts your app.
Wherever it runs, three things matter. The SMTP password and any API keys come from environment variables or the platform's secret settings. The state file lives somewhere that survives between runs: some platforms wipe local files on every deploy, so a small database is safer there. And every run writes a log line with its summary, so when a member says "I never got my alert", you can see what happened that evening.
Practice what you just read
- 5 code examples you can edit and run
- a practice exercise that checks your code
- a 5-question quiz
- 5 review cards for spaced repetition
Try a quiz question
Why save the state file after the emails are sent, not before?