Recipe Lookup with TheMealDB

Food Tracker (FastAPI)

Chapter 9 · Recipe Lookup with TheMealDB

Chapter 6's alerts query already knows what's expiring soon. This chapter takes that same list and asks a second free API, TheMealDB, what could actually be cooked with it — reusing every lesson Chapter 3 already taught about proxying an external API, plus one genuinely new gotcha this specific combination introduces.

A Recipe Cache, Same Pattern as Chapter 3

# models.py (appended) class RecipeCache(Base): __tablename__ = "recipe_cache" ingredient = Column(String, primary_key=True) meals = Column(String, nullable=False) # JSON array, stored as text cached_at = Column(DateTime, server_default=func.now())

Real Concurrency via asyncio.gather()

Multiple ingredients need looking up at once — the same fan-out shape every sibling course faces. FastAPI's own async model makes genuine concurrency the natural way to write this, not a special optimization bolted on afterward:

# routers/recipes.py import asyncio, json from database import SessionLocal async def lookup_ingredient(ingredient: str) -> list[dict]: key = ingredient.lower().strip().replace(" ", "_") db = SessionLocal() # a dedicated session for this one concurrent task try: cached = db.query(models.RecipeCache).filter_by(ingredient=key).first() if cached: return json.loads(cached.meals) async with httpx.AsyncClient() as client: response = await client.get( f"https://www.themealdb.com/api/json/v1/1/filter.php?i={key}", timeout=5.0, ) meals = response.json().get("meals") or [] db.merge(models.RecipeCache(ingredient=key, meals=json.dumps(meals))) db.commit() return meals finally: db.close() @router.get("/suggest") async def suggest_recipes(db: Session = Depends(get_db)): threshold = date.today() + timedelta(days=3) expiring = ( db.query(models.Item) .filter(models.Item.status == "active", models.Item.expiry_date.isnot(None)) .filter(models.Item.expiry_date <= threshold) .all() ) results = await asyncio.gather(*(lookup_ingredient(i.name) for i in expiring)) match_counts = {} for meals in results: for meal in meals: entry = match_counts.setdefault(meal["idMeal"], {**meal, "matchCount": 0}) entry["matchCount"] += 1 ranked = sorted(match_counts.values(), key=lambda m: m["matchCount"], reverse=True) return ranked[:10]
A real gotcha specific to this combination: one Session, many concurrent tasks
Every earlier chapter used the single request-scoped session from Depends(get_db) — perfectly fine when only one query runs at a time. asyncio.gather() runs several lookup_ingredient calls concurrently, and SQLAlchemy's classic Session is explicitly not designed to be shared across concurrently-running tasks — interleaved queries and commits against one shared session can leave its internal state genuinely inconsistent. The fix above is deliberate: lookup_ingredient opens and closes its own dedicated SessionLocal() rather than reusing the route's own db parameter, giving each concurrent task a session entirely its own. This is a real, specific consequence of combining FastAPI's async concurrency with SQLAlchemy's synchronous session model — not a gotcha any of this quartet's other three courses have to think about in quite the same way.
Two of four courses in this quartet share this advantage
Food Tracker (Django)'s own equivalent view named its own fan-out honestly as sequential — one TheMealDB call waiting for the previous one to finish, a real, admitted cost. asyncio.gather() here fires every ingredient's lookup concurrently instead, the exact same category of advantage Food Tracker (React + Express)'s own Promise.all already claimed for itself in that course's own Chapter 9 — two genuinely different languages, Python and JavaScript, each with a real async model, both reaching the same concurrent result for the same underlying reason: an async-first runtime turns out to matter for more than just how a single request feels to write.
The cache is per-ingredient, not per-suggestion
Caching keyed by ingredient means a cached "chicken" lookup gets reused the next time chicken appears in the alerts list, regardless of what else happened to be expiring alongside it that day — the same granular-caching principle as every sibling course's own recipe cache.

Where This Course Is Headed

Async patterns and background tasks next — a deeper look at async def, BackgroundTasks, and connection-pooling considerations, building directly on this chapter's own concurrency work.

Hands-On Exercises

Exercise 1

Explain why sharing the route's own Depends(get_db) session across every concurrent lookup_ingredient call would be unsafe, and what lookup_ingredient does instead to avoid the problem.

📄 View solution
Exercise 2

Explain why this course and Food Tracker (React + Express) both achieve real concurrent fan-out for the same underlying reason, despite being written in two completely different languages.

📄 View solution
Exercise 3

Explain why recipe_cache is keyed by ingredient rather than by the full combination of expiring items on any given day.

📄 View solution

Chapter 9 Quick Reference

  • Route: GET /suggest — asyncio.gather() fans out to TheMealDB per expiring ingredient, merges and sorts by match count
  • New table: recipe_cache, keyed by ingredient, storing the JSON meal list as text
  • Real gotcha: a shared synchronous SQLAlchemy Session is unsafe across concurrent asyncio tasks — each lookup_ingredient call opens and closes its own dedicated session
  • Real advantage: asyncio.gather() matches Food Tracker (React + Express)'s own Promise.all — two of four courses achieve genuine concurrency here, vs. Food Tracker (Django)'s own honestly-named sequential cost
  • Next chapter: Async Patterns & Background Tasks