Exercises — Week 6 — Local handler¶
Do these after reading Week 6. Local FastAPI + timeout/fallback is enough. No cloud deploy, no 100 concurrent users.
Predict before you run¶
If ainvoke sleeps 0.2s and wait_for is 0.05s, is fallback True? After a third distinct cache key with max_size=2, is the first question still a hit?
Runnable command¶
Each task has three hints, closed by default. Open only as far as you need.
1. /health and /chat¶
Copy the lesson’s FastAPI app. POST /chat must await chain.ainvoke({...}) (not agenerate on a string). Import time.
Checks:
GET /health→{"ok": True}POST /chatwith{"message": "password reset"}returns aresponseandlatency_msimport timeis used;ChatRequest.messageis required
Hint 1 — a nudge
You don't need to bind a port to test a handler. What does FastAPI give you for calling routes in-process?
Hint 2 — the approach
fastapi.testclient.TestClient(app) calls routes like requests would, no server. Check /health, a good /chat, and a /chat with an empty body — a required field should return 422 without your code running.
Hint 3 — most of the code
import asyncio
import time
from fastapi import FastAPI
from fastapi.testclient import TestClient
from langchain_core.language_models import FakeListChatModel
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableLambda
from pydantic import BaseModel
app = FastAPI()
chain = ChatPromptTemplate.from_template("{message}") | FakeListChatModel(responses=["Settings > Security."] * 10) | StrOutputParser()
class ChatRequest(BaseModel):
message: str
class ChatResponse(BaseModel):
response: str
latency_ms: float
fallback: bool = False
@app.get("/health")
def health():
return {"ok": True}
@app.post("/chat", response_model=ChatResponse)
async def chat(request: ChatRequest):
start = time.time()
text = await chain.ainvoke({"message": request.message})
return ChatResponse(response=text, latency_ms=(time.time() - start) * 1000)
client = TestClient(app)
print(client.get("/health").json())
print(client.post("/chat", json={"message": "password reset"}).json())
print(client.post("/chat", json={}).status_code)
2. Timeout → fallback¶
Wrap ainvoke in asyncio.wait_for(..., timeout=0.05) and use an LLM (or RunnableLambda) that sleeps 0.2s so the wait fires.
Checks:
- The response sets
fallback is True - The fallback string does not mention a refund or a dollar amount
Hint 1 — a nudge
A timeout you've never seen fire is a timeout you don't know works. How do you make the "model" reliably slower than the budget?
Hint 2 — the approach
An async def that await asyncio.sleep(0.2) wrapped in RunnableLambda is a slow model with no network. Catch asyncio.TimeoutError around wait_for and return a fixed, harmless fallback string with fallback=True.
Hint 3 — most of the code
async def slow_model(inputs: dict) -> str:
await asyncio.sleep(0.2)
return "late answer"
slow_chain = RunnableLambda(slow_model)
FALLBACK_TEXT = "Support is slow right now. Try the docs, or retry."
async def answer_with_budget(message: str, budget_s: float = 0.05) -> ChatResponse:
start = time.time()
try:
text = await asyncio.wait_for(slow_chain.ainvoke({"message": message}), timeout=budget_s)
fallback = False
except asyncio.TimeoutError:
text, fallback = FALLBACK_TEXT, True
return ChatResponse(response=text, latency_ms=(time.time() - start) * 1000, fallback=fallback)
print(asyncio.run(answer_with_budget("where is my refund?")))
3. FIFO cache¶
Put FifoCache(max_size=2) in front of identical questions.
Checks:
- Second identical question is a cache hit (
cached is Trueor tokens 0) - Inserting a third distinct key evicts the first (FIFO, not “LRU” unless you implemented LRU)
Hint 1 — a nudge
A plain dict remembers insertion order. What's the oldest key, and how do you get it without scanning?
Hint 2 — the approach
Copy the lesson's FifoCache. In front of the chain: get first; on a miss, call the chain and set. Return cached in the response so the test can see it. Then insert three distinct keys and look up the first.
Hint 3 — most of the code
class FifoCache:
def __init__(self, max_size: int = 100):
self.max_size, self._data = max_size, {}
def get(self, key: str):
return self._data.get(key)
def set(self, key: str, value: str) -> None:
if key not in self._data and len(self._data) >= self.max_size:
self._data.pop(next(iter(self._data)))
self._data[key] = value
cache = FifoCache(max_size=2)
def cached_answer(question: str) -> dict:
hit = cache.get(question)
if hit is not None:
return {"response": hit, "cached": True}
text = chain.invoke({"message": question})
cache.set(question, text)
return {"response": text, "cached": False}
for q in ("reset password", "reset password", "rotate key", "export csv", "reset password"):
print(q, cached_answer(q)["cached"])
Do not write a cloud deployment guide. A golden file (week 7) still beats this Dockerfile.
Expected observation¶
Open after you run
Health returns {"ok": True}. Timeout path sets fallback is True with no dollar amount. FIFO size 2 evicts key 1 when key 3 arrives.
Self-check¶
No cloud deploy, no 100 concurrent users. import time is actually used. A golden file still beats a Dockerfile.