Real-World Python Engineering
The final part: the Python that separates a script from a system. Memory-safe processing at scale, correct resource & connection handling, production resilience (rate-limit + circuit-breaker), config/logging done right, a real pytest toolkit, packaging your agent as an installable CLI, and the quality toolchain pros run in CI — capped by a real-world mini-project that ties it all together.
- Claude: an Anthropic API key (
ANTHROPIC_API_KEY) from console.anthropic.com +pip install anthropic - OpenAI: an OpenAI API key (
OPENAI_API_KEY) from platform.openai.com +pip install openai
.env file (never in code) and let the zero-argument client pick it up from the environment.Reading and learning works without any of this — run when you're ready. IDs/ARNs in examples are placeholders; swap in your own.Learning objectives
- Process large data without exploding memory (generators, streaming,
__slots__). - Manage resources & connections correctly (pools, cleanup, reentrancy).
- Implement production resilience: token-bucket rate limiting + a circuit breaker.
- Do configuration, logging, and error reporting the way production teams do.
- Write a real pytest suite: fixtures, parametrize, async, coverage, markers.
- Package your agent as an installable command-line tool and run a quality toolchain.
What this is (and isn't) optional, max-level
This page is maximal on purpose — it's the "learn as much as possible" material for someone who wants to build agents like a professional software engineer, not just make them work. None of it is required for the course or projects. Skim the headers; go deep on whatever matches what you're building. Everything is runnable and grounded in real agent scenarios.
1 · Memory & performance at scale expert
Here's a bug that has ambushed almost every engineer at least once. You write a little script that reads a log file, filters for errors, and works beautifully on the 5 MB sample on your laptop. You ship it. A month later it runs against the real log — 12 GB of it — and the process is killed by the OS with an out-of-memory error before it prints a single line. Nothing in your logic was wrong. The mistake was readlines(): you asked Python to pull the entire file into a list in one gulp, and a 12 GB file needs 12 GB of RAM you don't have.
The fix is a shift in how you move data through your program, and it rests on one idea worth internalizing: you rarely need all the data in memory at once — you only need one piece at a time. Think of drinking from a water fountain versus filling a swimming pool and then drinking. A generator is the fountain: it hands you the next line the instant you ask, and forgets the last one. Memory stays flat — a few kilobytes — whether the file is 1 MB or 1 TB, because you're never holding more than the current line.
This section teaches three levers, in order of how often you reach for them. Generators (functions with yield) turn "load everything, then process" into "process as it streams," and they compose — you can chain a reader into a parser into a filter and no intermediate list is ever built. __slots__ is the second lever: by default every Python object carries a private dictionary so you can bolt on arbitrary attributes, and that dictionary costs real memory; when you have millions of tiny, fixed-shape records (RAG chunks, log events), declaring __slots__ drops the dict and can shrink each object by half. The third lever is profiling — and it earns its place because of the single most common mistake in this whole area: optimizing the wrong thing. Programmers have famously bad intuition about where time goes. You'll swear a loop is the bottleneck, spend an afternoon tightening it, and discover the real cost was a network call you never looked at. So the rule is: measure first, optimize second, and — as the tip below stresses — in agent code the bottleneck is almost always API latency, not your Python.
readlines() materializes the entire file as one list in RAM — fine at 5 MB, fatal at 12 GB — while a generator pipeline passes a single line through read → parse → filter and holds nothing else, so memory is constant regardless of file size.
Compare the two rows top to bottom: the top row is the memory bomb, the bottom row is the fix — read each left to right, following the blue arrows.
- The top row feeds the whole
fileinto a single red "list in RAM" box that holds all lines at once. The note beneath it is the payoff-gone-wrong: a 12 GB allocation the machine can't satisfy, so the OS kills the process. - The bottom row is the generator pipeline:
file → read → parse → filter → one result. Each box does one small step and hands a single line to the next via the blue arrow — no box ever accumulates the full file. - The green box is the aha: the pipeline yields results one at a time, and the caption line below confirms memory stays flat (a few KB) no matter how big the file grows.
In short: the top row's memory scales with file size and crashes; the bottom row's stays constant because only one line is ever in flight.
Illustrative fragment — defines demo values / files are needed before this runs standalone.
python# ✗ loads the whole file into memory
lines = open("huge.log").readlines()
errors = [l for l in lines if "ERROR" in l]
# ✓ streams line by line — constant memory, even for a 10GB file
def error_lines(path):
with open(path) as f:
for line in f: # file objects are lazy iterators
if "ERROR" in line:
yield line # produce one at a time
# generator pipelines compose without intermediate lists
def parse(lines):
for l in lines:
yield l.split("|", 2)
first_10 = list(islice(parse(error_lines("huge.log")), 10)) # only reads what it needs
# __slots__: drop the per-instance __dict__ -> big memory + speed win for
# millions of small objects (e.g. chunk records in a RAG index)
class Chunk:
__slots__ = ("id", "text", "vec") # no arbitrary attributes, less RAM
def __init__(self, id, text, vec):
self.id, self.text, self.vec = id, text, vec
The big idea here is don't load a giant file into memory all at once. The first two lines read an entire log into a list — fine for a small file, fatal for a 10 GB one. The rest of the block shows the memory-safe way using generators (functions that hand back values one at a time).
open(path)inside awithblock gives you a file object you can loop over line by line. Python reads only one line at a time from disk, so memory stays flat no matter how huge the file is.yield lineis what makeserror_linesa generator. Instead of building a whole list and returning it,yieldproduces one value, pauses, and resumes on the next request. Nothing is computed until someone asks for it.parseis a second generator that takes the lines from the first and splits each one. Chaining generators like this is a pipeline — data flows through without ever creating a big intermediate list.islice(..., 10)pulls just the first 10 results, so the pipeline reads only as much of the file as it needs and then stops.__slots__ = ("id", "text", "vec")tells Python this object will only ever have those three attributes. That lets Python skip the per-object dictionary it normally keeps, saving a lot of RAM when you have millions of these little objects.
What the output means: Nothing prints on its own — these are building blocks. The payoff is that error_lines and parse use roughly the same tiny amount of memory whether the log is 1 MB or 10 GB, because they never hold the whole thing at once.
Try this: Picture a 10 GB log. The first approach (readlines()) would try to allocate 10 GB of RAM and crash; the generator version reads a line, checks it, and forgets it. That difference is why generators matter at scale.
python -m cProfile -s cumtime script.py, or py-spy top --pid <pid> on a running service. The bottleneck is almost never where you think — and in agent apps it's usually the API latency, not your Python, so concurrency (P5 §5) beats micro-optimization.__slots__ matters when the Ch 3 RAG index holds hundreds of thousands of chunks.2 · Resource & connection management expert
A support bot ran fine in the demo and fell over the moment real traffic hit it. Every incoming question created a fresh Anthropic() client "to be safe," each one opening its own pool of network connections. Under a hundred concurrent users the machine ran out of file descriptors — the operating system's hard cap on open connections — and new requests started failing with cryptic Too many open files errors. The code was correct in isolation and lethal at scale. The lesson: some things aren't free to keep creating, and forgetting to release them is a slow-motion crash.
The mental model that fixes this is a resource is something you borrow, and anything you borrow you must give back. A database connection, an HTTP client's connection pool, an open file, a lock — each is a finite thing the system lends you, and each will run out if you take more than you return. Picture a library with 50 copies of a book: as long as readers return them, everyone gets served; if a few people keep borrowing and never return, the shelf empties and the next reader is stuck. Leaked connections are exactly those unreturned books, and "under load" is just "lots of readers at once" — which is why leaks hide in testing and only bite in production.
Two rules cover almost everything. Rule one: reuse, don't recreate. Expensive-to-open, cheap-to-reuse resources — API clients above all — should be created once, at module load, and shared; the SDK pools connections behind that single client so a thousand requests ride the same warm pool. The common mistake is the well-meaning "fresh client per request" you saw in the story — it feels safer but it's the leak. Rule two: guarantee cleanup, even when things explode. This is what the with statement is for. A @contextmanager built on try/finally promises that the teardown runs no matter what — normal return, early return, or an exception blowing through the middle — so a connection is never orphaned by a crash. When the number of resources is dynamic (open N files, close them all), ExitStack does the same bookkeeping for a whole list at once. The habit to build: whenever you open something that must be closed, reach for with instead of a manual .close() you have to remember to call on every exit path — because you will forget one, and it'll be the one an exception takes.
Anthropic() per request opens a brand-new connection pool every time and eventually exhausts the OS file-descriptor cap under load; one module-level client shares a single warm pool across every request, so a thousand calls ride the same connections.
Look at the two halves side by side: the same three requests on the left column of each, and where their connections go on the right.
- On the left ("new client per request") every request fans out to its own red "new pool" — three requests, three separate pools. The note below shows where this leads under real traffic: the operating system's file-descriptor limit is hit and new requests fail with
Too many open files. - On the right ("one shared client") all three requests point at a single green "one warm pool" — the module-level client. Nothing new is opened per request.
- The payoff is the green box shared by every arrow: reused connections mean the service stays stable no matter how many requests arrive, which is exactly why the leak hides in a demo and only bites at scale.
In short: per-request clients multiply pools until the OS runs out; one shared client keeps a single pool warm for everyone.
Uses the client / objects set up earlier in this lesson, and needs ANTHROPIC_API_KEY set. Run the earlier blocks first.
python# ✗ creating a client per request leaks connections & is slow
def bad_handler(q):
client = Anthropic() # new connection pool every call!
return client.messages.create(...)
# ✓ create ONE client at module load, reuse it (the SDK pools connections)
client = Anthropic() # module-level singleton
def good_handler(q):
return client.messages.create(...)
# a reusable connection with guaranteed cleanup via a context manager
from contextlib import contextmanager
import sqlite3
@contextmanager
def readonly_db(path):
con = sqlite3.connect(f"file:{path}?mode=ro", uri=True)
try:
yield con
finally:
con.close() # ALWAYS closes, even on exception
with readonly_db("shop.db") as con:
rows = con.execute("SELECT ...").fetchall()
# ExitStack: manage a dynamic number of resources cleanly
from contextlib import ExitStack
with ExitStack() as stack:
files = [stack.enter_context(open(p)) for p in paths]
... # all closed when the block exits
This block is about not leaking limited resources — network connections, database handles, open files. Each has a hard cap, and forgetting to close them will eventually take a service down under load. The two rules shown: reuse one client instead of making new ones, and always clean up with a context manager.
bad_handlercreates a newAnthropic()client on every call. Each client opens its own pool of network connections, so this slowly leaks connections and is slow. The fix right below it creates one client at module load and reuses it everywhere.- The
@contextmanagerdecorator turnsreadonly_dbinto something you can use withwith. The code beforeyieldis setup (open the connection); the code after is teardown (close it). - The
try / finallyis the whole point:finallyruns no matter what — even if your query raises an error — socon.close()always happens and the connection is never leaked. with readonly_db("shop.db") as con:is how you use it. When thewithblock ends (normally or via an exception), Python automatically runs the teardown for you.ExitStackhandles the case where you don't know how many resources you'll open. Every file added withstack.enter_context(...)is closed together when the block exits — clean cleanup for a dynamic list of resources.
What the output means: Again, structure rather than printed output. The lesson is behavioural: the with versions guarantee the connection or files are closed even when something throws, which is what keeps a long-running service healthy.
Try this: Imagine the query on the SELECT line crashes. Trace what happens: the exception jumps to finally, con.close() still runs, then the error propagates. That guarantee is the reason to prefer with over manually calling .close().
readonly_db is exactly this pattern — and reusing one Anthropic() client (not one per request) is the fix for the connection leak that kills a naive support-bot under load (Ch 6 scaling).3 · Resilience patterns — rate limiting & circuit breaker expert · production
Two 3 a.m. pages, two different lessons. The first: an agent fanned out fifty tool calls in a burst, blew straight past the API's 10-requests-per-second quota, and got every call rejected with 429 Too Many Requests — the whole job failed not because anything was broken but because it asked too fast. The second, a week later: a downstream database went down, and instead of noticing, the agent retried every call, each one hanging for a 30-second timeout, threads piling up until the service itself became unresponsive. One agent was too eager; the other was too stubborn. Production code needs a guard against each.
These are genuinely different failures, and it's worth feeling the difference because beginners conflate them. Rate limiting is about you being too fast — it's self-restraint, keeping your own request rate under a quota that's working perfectly fine. A circuit breaker is about them being down — it's giving up quickly on a dependency that has already failed, so you stop throwing good requests after bad. Retry-with-backoff from P4 handles the transient middle ground (a blip that clears in a second); these two handle the extremes.
The analogies make them stick. A rate limiter is a token bucket: imagine a bucket that drips full at a steady rate, and every call must grab a token to proceed — burst through the tokens that have accumulated, then you're forced to wait for the drip. It naturally allows a short flurry (up to the bucket size) and then smooths you down to the sustainable rate, which is exactly how real quotas behave. A circuit breaker is the electrical breaker in your house: after too many faults it trips open and cuts the circuit, and while it's open it fails instantly — the whole point is that failing in a microsecond is vastly cheaper than waiting 30 seconds for a timeout, both for your latency and for the struggling dependency you'd otherwise keep hammering. The common mistake with breakers is forgetting the recovery half: a breaker that trips and never resets is just an outage, so it needs a cooldown after which it lets a trial call through to test whether the dependency has healed. Both patterns below are short, thread-aware where it matters, and drop straight into real agent code.
Follow the arrows clockwise from the green box on the left — each box is a state the breaker can be in, and each arrow is the event that moves it to the next.
- CLOSED (green) is the healthy state: calls flow through. The self-loop underneath it means every success resets the failure count back to zero. After N consecutive failures the blue arrow trips it right to OPEN.
- OPEN (red) is fail-fast: it refuses calls instantly instead of waiting on a dead dependency. When the cooldown ends it moves to HALF-OPEN (amber) to test the water.
- HALF-OPEN allows exactly one trial call. The payoff is the two arrows out of it: the blue "trial succeeds" arrow loops all the way back to CLOSED (recovered), while the red dashed "trial fails" arrow trips it straight back to OPEN for another cooldown.
In short: a breaker without that HALF-OPEN → CLOSED recovery path is just a permanent outage — the loop back is what lets it heal automatically.
ratelimit.pyimport time, threading
class RateLimiter:
"""Allow `rate` calls per second (token bucket). Thread-safe."""
def __init__(self, rate: float, burst: int):
self.rate, self.capacity = rate, burst
self.tokens = burst
self.updated = time.monotonic()
self.lock = threading.Lock()
def acquire(self):
with self.lock:
now = time.monotonic()
self.tokens = min(self.capacity,
self.tokens + (now - self.updated) * self.rate)
self.updated = now
if self.tokens < 1:
sleep = (1 - self.tokens) / self.rate
time.sleep(sleep) # wait for a token
self.tokens = 0
else:
self.tokens -= 1
limiter = RateLimiter(rate=10, burst=20) # 10/s, bursts to 20
def call(...): limiter.acquire(); return client.messages.create(...)
This is a token-bucket rate limiter — the standard way to stay under an API's calls-per-second quota. Picture a bucket that refills with tokens over time; every call must spend one token, and if the bucket is empty the call waits. rate is how fast tokens refill; burst is the bucket's max size (how many you can spend at once).
__init__stores the settings and starts with a full bucket (self.tokens = burst).time.monotonic()is a clock that only moves forward — perfect for measuring elapsed time (unlike wall-clock time, it can't jump backward).self.lock = threading.Lock()makes the limiter thread-safe: if several threads call at once, thewith self.lock:block lets only one adjust the token count at a time, so two threads can't both think a token is free.- Every
acquire()first refills:(now - self.updated) * self.rateis how many tokens accumulated since last time.min(self.capacity, ...)caps it so the bucket never overflows pastburst. - If there's less than one token, we
time.sleep(...)for exactly long enough for one token to refill, then proceed. Otherwise we simply spend a token (self.tokens -= 1) and return immediately.
What the output means: A caller doing limiter.acquire() before each request will be allowed to burst up to 20 quick calls, then automatically throttle to about 10 per second — sleeping just enough to stay under the limit.
Try this: Set rate=1, burst=1 and imagine calling acquire() in a tight loop: the first returns instantly, then each one sleeps ~1 second. That's the bucket forcing you down to 1 call/sec.
breaker.pyimport time
class CircuitBreaker:
"""Trip open after N consecutive failures; recover after a cooldown."""
def __init__(self, threshold=5, cooldown=30):
self.threshold, self.cooldown = threshold, cooldown
self.failures, self.opened_at = 0, None
def call(self, fn, *a, **k):
if self.opened_at and time.monotonic() - self.opened_at < self.cooldown:
raise RuntimeError("circuit open — failing fast") # don't even try
try:
result = fn(*a, **k)
except Exception:
self.failures += 1
if self.failures >= self.threshold:
self.opened_at = time.monotonic() # trip open
raise
self.failures, self.opened_at = 0, None # success resets
return result
A circuit breaker protects you from a dependency that has gone down. Like an electrical breaker, it "trips" after too many failures and then fails fast for a while instead of letting every call hang and pile up. It has two settings: threshold (how many failures trip it) and cooldown (how long it stays tripped).
- State lives in two fields:
self.failurescounts consecutive failures, andself.opened_atrecords when the breaker tripped (orNoneif it's healthy). call(self, fn, *a, **k)wraps any function. First it checks: if the breaker is open and we're still inside the cooldown window, it raises immediately with"circuit open — failing fast"— it doesn't even attempt the call.- It then tries to run
fn. On an exception it bumpsself.failures; once that reachesthreshold, it setsself.opened_atto now (trips open) and re-raises the error. - On success the last line resets
self.failuresto 0 and clearsopened_at— so a single good call closes the breaker again and normal service resumes.
What the output means: With threshold=5: the first 5 failures pass the error through while counting up; the 6th and later calls fail instantly for 30 seconds without touching the broken dependency, giving it time to recover.
Try this: Compare the cost: without a breaker, a dead backend means every call waits for a timeout (seconds each) and stacks up. With one, calls fail in microseconds after it trips — saving latency, threads, and money.
4 · Configuration & logging, done right production
The worst production incident is the one that could have been caught at startup. A team deployed on a Friday; the app booted cleanly, health checks went green, everyone went home. The first user request that actually called the model failed — the API key env var had a typo, so os.environ.get("ANTHROPIC_API_KEY") quietly returned None, and that None traveled halfway through the request before erupting as a baffling TypeError deep inside the SDK. Hours of Friday-night debugging for a missing character. Config and logging are the unglamorous plumbing that decides whether your 3 a.m. self can figure out what went wrong in two minutes or two hours.
Take configuration first. The instinct is to sprinkle os.environ.get("...") wherever you happen to need a value, and it works — right up until it doesn't. The problems are subtle: env vars are always strings, so "1024" is text until you remember to int() it (and one forgotten conversion is a bug); a missing value returns None instead of complaining, so the failure surfaces far from its cause; and nobody can look in one place to see what the app even needs to run. The fix is a single typed settings object validated once at boot. Think of it as a bouncer at the door: every required value is checked and type-converted the moment the program starts, and if anything's missing the app refuses to launch — with a clear message naming the exact field. Failing loud at startup is a feature; the alternative is failing quietly in front of a user.
Then logging, where the common mistake is reaching for print(). Prints are fine for a script you watch by eye, but useless for a system you operate: you can't set a severity level, you can't route them, and — the real killer — you can't query them. Free-form text like print(f"call for {user} took {ms}ms") reads fine to a human and is invisible to a machine; there's no reliable way to later ask "what's the average latency per model today?" across a million such lines. The professional move is structured logging: emit each event as a JSON object with named fields (a trace id, the model, token counts, a duration) so a log dashboard can filter, aggregate, and alert on them. One JSON line per request is the difference between logs you scroll past and logs you can actually investigate. The two labs below build exactly these — typed settings with pydantic, and a JSON log formatter — the same pair every production team ends up with.
os.environ.get returns None on a missing key and blows up deep in a request, while a typed Settings object validated once at startup refuses to launch and names the field; a print() sentence is unqueryable, while one JSON line per event is filterable and aggregatable.
Read the top band left-vs-right for configuration, then the bottom band left-vs-right for logging — the red side is the trap, the green side is the professional fix.
- The top-left red path is scattered
os.environ.get: a missing key quietly returns None and rides along until it erupts as a crypticTypeErrordeep inside a live request. The top-right green path is a typedSettingsobject that validates every value once at boot and refuses to launch — naming the exact missing field. - The bottom-left red box is a
print()sentence: readable to a human, invisible to a machine — you cannot filter or aggregate it. The bottom-right green box is one JSON line per event, with named fields a log dashboard can query. - The payoff is where each failure surfaces: green catches the config error at startup (cheap) and turns logs into data you can investigate — red defers both to a 3 a.m. incident.
In short: validate config once at boot and emit structured JSON logs — errors surface early and logs become queryable, instead of failing quietly in front of a user.
config.pyfrom pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_prefix="AGENT_")
anthropic_api_key: str # required — fails fast if missing
model: str = "claude-opus-4-8"
max_tokens: int = 1024
confidence_threshold: float = 0.6
rung: str = "observe"
settings = Settings() # validated once at startup; typed everywhere after
This replaces scattered os.environ.get("...") calls with one typed, validated settings object. It uses pydantic-settings, which reads values from environment variables (and a .env file), converts them to the right type, and fails at startup if something required is missing.
- The class inherits from
BaseSettings. Themodel_configline says: load from a file named.env, and every env var is prefixed withAGENT_(soanthropic_api_keycomes fromAGENT_ANTHROPIC_API_KEY). - Each attribute is a setting with a type.
anthropic_api_key: strhas no default, so it's required — if it's missing, creating the object errors immediately instead of failing confusingly mid-request later. - The others have defaults (
model,max_tokens = 1024,confidence_threshold = 0.6). Pydantic also coerces types: an env var is always text, butmax_tokens: intmeans the string"1024"is turned into the number 1024 for you. settings = Settings()runs the validation once, at import time. After that,settings.modeland friends are typed and safe to use everywhere in the codebase.
What the output means: If the API key isn't set, the program stops at launch with a clear validation error naming the missing field — far better than a cryptic failure halfway through handling a user request.
Try this: Think about the alternative: with os.environ.get(), a missing key returns None and blows up later with a confusing message. "Fail fast at startup" means you find out the moment you run, not in production.
logging_setup.pyimport logging, json, sys
class JsonFormatter(logging.Formatter):
def format(self, record):
return json.dumps({
"ts": self.formatTime(record),
"level": record.levelname,
"msg": record.getMessage(),
**getattr(record, "extra_fields", {}), # attach trace_id, tokens...
})
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
logging.basicConfig(level=logging.INFO, handlers=[handler])
log = logging.getLogger("agent")
# one structured line per request -> queryable in any log tool
log.info("llm_call", extra={"extra_fields":
{"trace_id": "abc", "model": "opus", "out_tokens": 120}})
This sets up structured logging: instead of printing plain sentences, each log line is a JSON object. Machines can filter and search JSON logs ("show me all calls where out_tokens > 100"), which plain text makes painful. This is the format real log dashboards expect.
JsonFormattersubclasses Python's built-inlogging.Formatterand overridesformat. Whatever this method returns is the text of one log line — here, a JSON string built withjson.dumps(...).- The dict includes a timestamp, the level (INFO/WARNING/...), and the message. The
**getattr(record, "extra_fields", {})line merges in any custom fields you attached to that log call, like atrace_idor token counts. - The
handlerdecides where logs go (here,sys.stdout) and uses our JSON formatter.basicConfig(level=logging.INFO, ...)wires it up and sets the minimum level to record. - The final
log.info("llm_call", extra={"extra_fields": {...}})shows the payoff: you log an event plus structured data (trace_id,model,out_tokens) that flows straight into the JSON line.
What the output means: Each call to log.info(...) prints one line like {"ts": "...", "level": "INFO", "msg": "llm_call", "trace_id": "abc", "out_tokens": 120} — a record a log tool can index and query.
Try this: Compare to print(f"call for {trace_id}"). The JSON version lets you later ask a dashboard "average out_tokens per model today?" — impossible to do reliably against free-form text.
Settings upgrades the scattered os.environ.get calls into one validated object — and fails fast at startup if the API key is missing (better than a confusing error mid-request). The JSON formatter is the production form of the Ch 6 observability logging, ready to ship to a dashboard.5 · Real pytest — fixtures, parametrize, async, coverage essential for shipping
There's a moment every project hits where "it works on my machine" stops being good enough. You change one line to fix a bug, and something three files away silently breaks — you don't find out until a user does. Tests are the safety net that turns "I hope I didn't break anything" into "I know I didn't, because the suite is green." The engineers who move fastest aren't the ones who skip tests; they're the ones whose tests let them change code fearlessly, because a red bar catches the mistake in seconds instead of in production.
The mistake worth naming up front is treating tests as a chore you bolt on at the end — a folder of near-duplicate functions you copy-paste and dread maintaining. Real pytest is the opposite: a small set of tools that make tests cheap to write and read. The four that carry most of the weight, in plain terms: a fixture is shared setup you write once and any test can ask for by name (here, a fake model client so tests never spend money or hit the network); parametrize runs the same test body over a table of inputs, so a golden set of examples becomes a dozen tests from one function instead of a dozen copies; pytest.raises lets you assert that bad input is correctly rejected — testing the sad path, which is where most real bugs live; and markers are labels that let you slice the suite, most importantly to separate fast, free, key-less unit tests from slow, paid, model-calling evals.
That last split is the one insight that makes testing an agent practical rather than painful, so it's worth stating plainly: most of your logic doesn't need the model at all. Retrieval ranking, SQL guards, parsing, routing — you can test all of it with a mocked client, deterministically, in milliseconds, on every single commit at zero cost. The handful of tests that genuinely need to call Claude — "is the answer actually good?" — get tagged @pytest.mark.eval and run on pull requests or nightly, when spending a few cents and a few seconds is fine. Blur that line and you get the worst of both worlds: a suite too slow and expensive to run often, so nobody runs it, so it rots. Keep it sharp — pytest -m "not eval" in CI, the eval suite on demand — and you get a fast green bar you trust and a quality gate that still catches regressions in the model's actual behaviour. The lab builds the whole toolkit on top of the "mock the model" technique from P5 §8.
@pytest.mark.eval and run on PRs or nightly, so the default suite stays fast and green.
Start at the top box and follow the two arrows down: the suite forks by one question — does this test call the model? — and each fork runs on a different cadence.
- The green left branch is the bulk of your tests: they run against a mocked client (built with a fixture, expanded with
parametrize), so they need no API key, cost nothing, and finish in milliseconds. - The amber right branch is the small set of tests tagged
@pytest.mark.evalthat genuinely call Claude — slow, paid, and key-requiring, so you don't want them on every save. - The payoff is the bottom row of commands:
pytest -m "not eval"is what CI runs on every commit (fast, free), whilepytest -m evalruns the model-calling tests on PRs or nightly. Keeping the line sharp is what keeps the default bar fast enough that people actually run it.
In short: tag the model-calling tests as evals and run only the mocked ones on every commit — a fast green bar by default, a quality gate on demand.
Requires: pip install pytest
conftest.py + test_agent.pyimport pytest, types
from unittest.mock import MagicMock
# a fixture: reusable setup, injected by name into any test
@pytest.fixture
def mock_client():
c = MagicMock()
c.messages.create.return_value = types.SimpleNamespace(
content=[types.SimpleNamespace(type="text", text="ok")],
stop_reason="end_turn")
return c
# parametrize: run the SAME test over many inputs (a mini eval-in-tests)
@pytest.mark.parametrize("query,expected_kb", [
("reset password", "kb-1"),
("refund window", "kb-2"),
("enable 2fa", "kb-3"),
])
def test_retrieval(query, expected_kb):
from agent import kb
_, ids = kb.context_for(query)
assert expected_kb in ids # 3 tests from one function
# testing exceptions
def test_bad_sql_is_blocked():
from agent import db
with pytest.raises(ValueError, match="blocked"):
db.run_query("DROP TABLE orders")
# async tests (needs pytest-asyncio)
@pytest.mark.asyncio
async def test_async_path():
result = await some_async_agent("q")
assert result
# markers to separate fast unit tests from slow eval tests
@pytest.mark.eval # run with: pytest -m eval (needs an API key)
def test_real_quality(): ...
terminalpytest -v # run all
pytest -m "not eval" # fast, key-free tests only (CI on every commit)
pytest --cov=agent # coverage report (needs pytest-cov)
pytest -x -q # stop at first failure, quiet
This is the real pytest toolkit. pytest is Python's testing framework: you write functions named test_*, use assert to state what should be true, and pytest runs them and reports pass/fail. This block shows the four features you'll actually use: fixtures, parametrize, exception tests, and markers.
- A fixture (
@pytest.fixtureonmock_client) is reusable setup. Any test that takes an argument namedmock_clientautomatically receives what the fixture returns. Here it builds aMagicMock— a fake Anthropic client that returns a canned reply, so tests never hit the real API. - parametrize runs the same test over a table of inputs. The three
(query, expected_kb)rows become three separate tests from one function — a compact way to check many cases (a mini eval). with pytest.raises(ValueError, match="blocked"):asserts that the code inside must throw aValueErrorwhose message contains "blocked". This is how you test that bad input is correctly rejected.- Markers label tests.
@pytest.mark.asynciolets anasynctest run;@pytest.mark.evaltags the slow, API-key-requiring tests so you can run or skip them as a group. Theterminalblock below shows the commands:pytest -m "not eval"runs only the fast, free tests.
What the output means: Running pytest -v lists each test and its result. The parametrized test_retrieval shows up as three lines (one per row), and test_bad_sql_is_blocked passes only if the query really was blocked with that error.
Try this: Look at the terminal commands: pytest -m "not eval" is what CI runs on every commit (fast, no API key), while pytest -m eval runs the model-calling tests on demand. Splitting them this way keeps your test suite fast and free by default.
@pytest.mark.eval to run on PRs / nightly (with a key). parametrize turns your golden set into a clean table of test cases. This is exactly how the course's projects split "tests" (no key) from "evals" (key) — now with the real pytest machinery.tests/ dir; this is how you'd grow them properly. parametrize over a golden set is the bridge between the Ch 5 eval concept and a real test suite.6 · Packaging your agent as an installable CLI production
You built something genuinely useful — and then watched a teammate struggle for twenty minutes just to run it. "Which folder do I cd into? Do I run python main.py or python -m agent? Why does it only work from the repo root? What are all these ModuleNotFoundErrors?" The tool is fine; the packaging is the barrier. A folder of scripts is something only its author can run confidently. A package is something anyone can install with one command and invoke by name from anywhere — and that gap is what separates a personal hack from a tool your team adopts.
The idea to hold onto is that installing turns your code from a location into a name. Right now your agent lives at a path — it only runs if you're standing in the right directory with the right files around you. Packaging registers it with Python itself, so pip install copies (or links) it into the environment and, crucially, creates a command: after installing, typing devops-agent "..." in any directory just works, the same way git or ruff do. The single file that makes this happen is pyproject.toml — the modern, standardized manifest that declares three things: what your project is (name, version, the Python it needs), what it depends on (the libraries pip should pull in), and what command it exposes (the [project.scripts] entry point that maps a command name to a function to call).
Two details are worth flagging because they trip up newcomers. The first is the entry point: a line like devops-agent = "devops_agent.cli:main" reads as "the command devops-agent should run the main function inside devops_agent/cli.py" — that colon-separated module:function form is the whole mechanism behind every CLI tool you've ever installed. The second is the -e (editable) flag, and skipping it is the classic mistake: a plain pip install . copies a snapshot of your code, so every edit means reinstalling before you can test it — maddening during development. pip install -e . instead points the install at your source folder, so you edit a file and re-run the command instantly, no reinstall. That editable install is simply how you develop a package day to day; the non-editable install is for when you actually ship it. The lab shows the full pyproject.toml plus the two-line install-and-run, including uv, the fast modern alternative to pip/venv.
pyproject.toml's [project.scripts] entry point plus pip install -e . registers it with Python so the tool runs by name from anywhere — the same way git or ruff do.
Read left to right: a folder of scripts flows through two pieces of packaging and comes out the other side as a real command.
- The red box on the left is where you start — a folder of scripts that only works if you're standing in the right directory with the right files around you.
- The middle two boxes are the mechanism:
pyproject.tomldeclares a[project.scripts]entry point, andpip install -e .registers the project with Python. The line below spells out the entry point — themodule:functionform that maps the command name to the function it runs. - The green box is the payoff: after installing, the command runs by name from any directory. The bottom note explains why you use
-e— the editable install points at your source, so you edit a file and re-run instantly instead of reinstalling.
In short: a [project.scripts] entry point plus an editable pip install -e . turns your folder-bound scripts into a named command anyone can run from anywhere.
pyproject.toml[project]
name = "devops-agent"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = ["anthropic>=0.40", "pydantic>=2", "python-dotenv"]
[project.scripts]
devops-agent = "devops_agent.cli:main" # `devops-agent` on the command line
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
terminalpip install -e . # editable install
devops-agent "checkout-api is crashing" # now a real command
# modern, fast alternative to pip/venv:
uv venv && uv pip install -e . # uv — much faster
This is a pyproject.toml — not Python, but the config file that turns a folder of scripts into an installable package. It's the modern standard: one file describing what your project is, what it needs, and how to build it. It's organized into sections written in [brackets].
[project]declares the package's identity: itsname(what you'dpip install),version, the Python it needs (requires-python), and itsdependencies— the other libraries pip should install alongside it.[project.scripts]is the magic that creates a command. The linedevops-agent = "devops_agent.cli:main"means: when someone typesdevops-agentin the terminal, run themainfunction in thedevops_agent/cli.pyfile.[build-system]tells pip which tool to use to actually build the package (herehatchling). You rarely touch this — it's boilerplate that makes the install work.- The
terminalblock shows the payoff:pip install -e .installs your project in editable mode (code changes take effect without reinstalling), and afterwardsdevops-agent "..."is a real command anyone on your team can run.
What the output means: After pip install -e ., typing devops-agent "checkout-api is crashing" anywhere runs your agent — it behaves like any installed CLI tool, not a script you have to cd into.
Try this: Note -e (editable): without it you'd reinstall after every code edit. With it, the install points at your source folder, so you edit and re-run instantly — the normal way to develop a package.
cli.py files using argparse. The [project.scripts] entry point turns one into an installed command. For richer CLIs (subcommands, colors, help), typer or click are worth learning — but argparse ships with Python and is enough for most agents.cli.py entry points — this is how you'd ship them as installable tools a team runs, rather than files they cd into.7 · The code-quality toolchain production
The most exhausting code review is the one that argues about spaces. A pull request comes in; three comments are about a real bug and fifteen are about indentation, import order, an unused variable, and whether there should be a blank line here. Everyone's time drains into bikeshedding, and the actual logic gets a fraction of the attention it deserves. The fix isn't discipline — it's automation. A quality toolchain is a set of small, fast programs that enforce the boring stuff mechanically, so humans get to spend their judgment on the things that actually need judgment.
The mental frame is that these tools form a gate every change passes through before it can merge, and each guards a different kind of mistake — they don't overlap, they layer. A linter/formatter (ruff, now the fast standard that replaces black+flake8+isort in one tool) settles style and catches obvious slips like unused imports — end of debate, the formatter decides. A type checker (mypy or pyright) reads the type hints you wrote in P5 and proves you're not passing a str where an int is expected — a whole class of bug caught without running the code. Tests plus coverage (pytest --cov) prove the behaviour is correct and show you which lines nobody exercised. And pre-commit is the piece that ties it together: it runs all of the above automatically before each git commit, so bad code can't even get committed, let alone merged.
The common mistake here is treating this as all-or-nothing and therefore doing none of it — "we'll add quality tooling when we have time," which never comes. So adopt a minimum viable gate and grow it: ruff for format and lint, mypy for types, and pytest -m "not eval" for the fast tests, all wired into pre-commit, with the paid eval suite running on pull requests. That's a couple of hours of setup that pays for itself the first week, because every one of those checks is a bug or an argument you'll never have to have manually again. It's the same CI discipline the course's Ch 5/6 gate sections describe, made concrete with the specific tools professional teams actually run — all fast enough to run on every save.
ruff settles style, mypy proves the types, pytest -m "not eval" checks behaviour — all fired automatically by pre-commit, so a change that fails any one of them can't even be committed, let alone merged.
Read left to right: a code change enters on the left and must pass through each box in turn before it reaches the green "merge" on the right.
- The blue arrows are the happy path — a change flows
change → ruff → mypy → pytest → mergeonly if every stage is satisfied. - Each box guards a different class of mistake, labelled below it:
ruffcatches style slips and unused imports,mypycatches wrong types without running the code, andpytest -m "not eval"catches broken behaviour with the fast, key-free tests. - The dashed downward arrows are the payoff: if any check fails, the commit is blocked — nothing merges.
pre-commitis what runs the whole row automatically before each commit, so bad code never gets in.
In short: three non-overlapping checks wired into pre-commit form a gate every change must clear, turning the boring stuff into automation instead of code-review arguments.
| Tool | Does | Command |
|---|---|---|
| ruff | Lint + format (replaces flake8/black/isort) — extremely fast | ruff check . && ruff format . |
| mypy / pyright | Static type checking — enforces the P5 §1 types | mypy agent/ |
| pytest + pytest-cov | Tests + coverage | pytest --cov |
| pre-commit | Run all the above automatically before each git commit | pre-commit run --all |
| uv | Fast dependency & venv manager (Rust-based) | uv pip install -r requirements.txt |
ruff (format + lint), mypy (types), pytest -m "not eval" (fast tests) on every commit via pre-commit, and the eval suite on PRs. That's the same discipline the course's CI-gate sections (Ch 5/6) describe, with concrete tools.Capstone mini-project · a production-shaped agent runner putting it ALL together
Here's the difference between a tutorial and a system, in one screen. Every pattern on this page has, so far, stood alone — a rate limiter here, a breaker there, logging in its own box. That's how you learn them, but it's not how they live. In real code they all wrap the same call to the model, interleaved, and the interesting part is how they fit together without stepping on each other. This capstone is a single file that does exactly that: the skeleton a production agent's core actually has, with the demo agent logic stripped out so you can see the plumbing plainly.
Don't try to memorize it — read it as a checklist. The value isn't in reproducing the code from scratch; it's in being able to point at each part and name the pattern it represents, because that's the skill that transfers to whatever you build next. Watch how one ask() call threads through the whole stack: the breaker checks whether the dependency is even worth calling, the rate limiter throttles so you stay under quota, the retry loop absorbs transient blips with jittered backoff, a success resets the breaker and emits a structured log line with a trace id and timing, and a total failure raises a clean custom exception. Seven patterns you met separately, now cooperating on every single request.
The mental model to carry away is that this is a shape, not a specific program. The ask() method here just calls the model once; swap its body for a real agent loop — tool dispatch, RAG retrieval, a multi-step plan — and the surrounding resilience, throttling, config, and observability stay exactly the same. That's the whole point of learning these as reusable patterns rather than one-off tricks: the production skeleton is identical behind all six of the course's projects, and only the middle changes. If you can go through the code below and put a name beside every piece — reused client, token bucket, circuit breaker, retry with backoff, custom exception, structured logging, request-scoped context — then you own the production skeleton, and the rest is filling in the agent.
ask() passes the breaker gate, spends a rate-limit token, and calls the model inside a retry loop — a success resets the breaker and emits a structured log line, while a failure backs off and retries, tripping the breaker once failures reach the threshold.
Read the middle column top to bottom — that's the order the checks run for one call — then follow the two branches at the bottom for how the call ends.
- An
ask(prompt)first hits the red breaker gate: if the breaker is open it fails fast and never touches the dependency. Otherwise it drops to the amber rate limiter, which spends a token (sleeping if the bucket is empty), then into the call-model retry loop. - The green "success" branch (left) is the happy ending: the breaker's failure count resets and one structured
"ok"log line with a trace id and timing is emitted. - The red dashed "error" branch (right) counts the failure, computes a jittered backoff, and loops back up to the retry loop; once failures reach the threshold it trips the breaker — which is what makes the next call fail fast at the top.
In short: the vertical spine is the guardrails (breaker → limiter → retry) and the two branches are the outcomes — the same shape wraps every project's agent loop; only the "call model" box changes.
runner.pyimport time, random, uuid, logging, contextvars
from anthropic import Anthropic, RateLimitError, APIStatusError
log = logging.getLogger("agent")
trace_id = contextvars.ContextVar("trace_id", default="-")
client = Anthropic() # reused (§2)
class AgentError(Exception): ... # custom exception (P4)
class Runner:
def __init__(self, rate=8, breaker_threshold=5):
self._tokens, self._rate, self._updated = rate, rate, time.monotonic()
self._fails, self._opened = 0, None
self._threshold = breaker_threshold
def _rate_limit(self): # token bucket (§3)
now = time.monotonic()
self._tokens = min(self._rate, self._tokens + (now-self._updated)*self._rate)
self._updated = now
if self._tokens < 1:
time.sleep((1-self._tokens)/self._rate); self._tokens = 0
else: self._tokens -= 1
def ask(self, prompt, *, max_retries=4):
if self._opened and time.monotonic()-self._opened < 30:
raise AgentError("circuit open") # fail fast (§3)
for attempt in range(max_retries):
self._rate_limit()
try:
t0 = time.monotonic()
r = client.messages.create(model="claude-opus-4-8",
max_tokens=1024, messages=[{"role":"user","content":prompt}])
self._fails, self._opened = 0, None # reset breaker
log.info("ok", extra={"extra_fields":{"trace_id":trace_id.get(),
"ms":round((time.monotonic()-t0)*1000),
"out":r.usage.output_tokens}})
return next((b.text for b in r.content if b.type=="text"), "")
except (RateLimitError, APIStatusError) as e:
self._fails += 1
if self._fails >= self._threshold: self._opened = time.monotonic()
delay = min(30, 2**attempt + random.uniform(0,1)) # backoff (P4)
log.warning("retry", extra={"extra_fields":{"trace_id":trace_id.get(),
"attempt":attempt, "delay":round(delay,1)}})
time.sleep(delay)
raise AgentError("exhausted retries")
def handle(prompt, tenant):
trace_id.set(str(uuid.uuid4())) # request-scoped (§P5 contextvars)
return Runner().ask(prompt)
This is the capstone: one file that combines every production pattern from the lesson into a realistic agent runner. Read it as a checklist — each piece you learned separately appears here, working together. Don't worry about memorizing it; understand which pattern each part represents.
- Setup (top):
client = Anthropic()is created once and reused (§2).trace_idis acontextvars.ContextVar— a per-request id so every log line from one request can be tied together.AgentErroris a custom exception type. - _rate_limit: the same token-bucket logic from §3, inlined — refill tokens based on elapsed time, and either sleep for a token or spend one. This throttles the agent to stay under quota.
- ask (the circuit breaker + retry loop): first it fails fast if the breaker is open (§3). Then it loops up to
max_retriestimes: rate-limit, call the model, and on success reset the breaker and log a structured "ok" line with timing and token counts. - On failure: it counts the failure (tripping the breaker at the threshold), computes a
2**attempt + randomjittered backoff delay (P4), logs a "retry" line, and sleeps before trying again. If all retries are exhausted it raisesAgentError. - handle: the entry point sets a fresh
trace_idper request (so logs are traceable) and delegates toRunner().ask(...). Swap the body ofaskfor a real agent loop and this is a shippable core.
What the output means: A successful call returns the model's text and emits one JSON "ok" log line (with trace_id, latency in ms, and output tokens). Failures emit "retry" lines and back off; a truly dead dependency trips the breaker so later calls fail instantly.
Try this: Go through the code and name the pattern beside each part: reused client, token bucket, circuit breaker, retry with backoff, custom exception, structured logging, request-scoped trace id. If you can point to all seven, you've got the production skeleton.
ask()'s body for the project's agent loop.The complete Python journey expert
| Part | Level | You can now… |
|---|---|---|
| P1 | Beginner | read & write any lab |
| P2 | Beginner→Int. | handle messages, chunks, JSON, logs |
| P3 | Intermediate | structure the agent: modules, OOP, dataclasses, enums |
| P4 | Int.→Advanced | Pydantic, exceptions, streaming, async, secrets |
| P5 | Expert | typing, functools/itertools, concurrency, testing the model |
| P6 (here) | Production | scale, resilience, config, real pytest, packaging, quality |
🎯 Interview practice interview
The interview questions this topic gets asked — worked, with code. For the full pattern catalog see A9 · Big Tech AI-engineering patterns.
"Limit to N calls/sec." Refill tokens over time; allow a call only if a token is available.
pythonimport time
class RateLimiter:
def __init__(self, rate, per=1.0):
self.rate, self.per = rate, per
self.tokens = rate
self.updated = time.monotonic()
def allow(self):
now = time.monotonic()
self.tokens = min(self.rate,
self.tokens + (now - self.updated) * self.rate / self.per)
self.updated = now
if self.tokens >= 1:
self.tokens -= 1
return True
return False
The interview version of the rate limiter. The task: "limit to N calls/sec." The key insight interviewers want is the token bucket — you don't track a list of timestamps, you track a refilling count of tokens and check if one is available.
__init__storesrate(tokens perperseconds), starts the bucket full (self.tokens = rate), and records the current time withtime.monotonic().- In
allow(), the refill formula(now - self.updated) * self.rate / self.peradds tokens for the time that has passed, andmin(self.rate, ...)caps the bucket so it can't overfill. - If at least one token is available, it spends one (
self.tokens -= 1) and returnsTrue(call allowed); otherwise it returnsFalse(call denied).
What the output means: allow() returns True when a call is permitted and False when you'd exceed the rate — the caller decides whether to drop, queue, or wait. (The §3 version sleeps instead of returning False; same math, different policy.)
Try this: Be ready to explain why token-bucket beats "store the last N timestamps": it's O(1) memory and time, handles bursts naturally via the bucket size, and needs no cleanup of old entries.
Open after N failures, fail fast during a cooldown, then half-open to test recovery.
pythonclass CircuitBreaker:
def __init__(self, threshold=5):
self.fails = 0; self.threshold = threshold; self.open = False
def call(self, fn):
if self.open:
raise RuntimeError("circuit open")
try:
r = fn()
except Exception:
self.fails += 1
if self.fails >= self.threshold: self.open = True
raise
self.fails = 0
return r
The interview version of the circuit breaker. Explain it as a state machine: closed (calls flow), open (fail fast after too many errors), and half-open (after a cooldown, let one trial call test whether the dependency recovered). This minimal version shows closed and open.
__init__tracks a failure count (self.fails), the trip threshold, and a booleanself.openthat startsFalse(closed / healthy).call(self, fn)first checks: ifself.openis true, raise"circuit open"immediately — the fail-fast behaviour that stops hammering a dead dependency.- It runs
fn(); on an exception it incrementsself.fails, and once that hitsthresholdit flipsself.open = True(trips) and re-raises. - On success it resets
self.fails = 0and returns the result. In a full version you'd add a cooldown timer and the half-open trial call (mentioned in the prompt) to auto-recover — the §3 breaker shows the cooldown piece.
What the output means: While closed, calls pass through and errors count up. After threshold failures the breaker opens and every subsequent call() raises instantly until it's reset.
Try this: In the interview, sketch the three states and the transitions between them. Mention that real breakers add a cooldown and a half-open probe so they recover automatically instead of staying open forever.
🪜 Practice ladder beginner → industry
Six graded exercises, easy to real-world. Try each before opening its solution.
Context: Log files and datasets routinely dwarf available RAM, so readlines() is a memory bomb waiting to happen. A generator that yields matching lines one at a time keeps memory flat regardless of file size — the foundational streaming pattern for any log or data pipeline.
Your task: Write a generator error_lines(source) that yields only the lines containing ERROR, keeping memory constant, and demonstrate it on an in-memory list of lines (a file object would iterate the same way).
Requirements:
- The function is a generator that
yields matching lines lazily - Only lines containing
ERRORare emitted; others are skipped - It never accumulates all lines into a list internally
- The demo runs offline against a list, noting a real file iterates identically
- Confirm the count of error lines matches expectation
💡 Hint: Iterating a file object yields lines just like iterating a list, so the same generator body works on both — filter inside the loop and yield the survivors.
Show solution
Generators keep memory constant regardless of file size. Runnable (adapted to a list so it runs offline):
def error_lines(lines):
for line in lines: # file objects iterate the same way
if "ERROR" in line:
yield line
sample = ["INFO ok\n", "ERROR auth failed\n", "ERROR 503\n", "INFO served\n"]
errs = list(error_lines(sample))
print(len(errs), "error lines") # 2 error lines
Context: When you hold millions of small records — log entries, events, embeddings metadata — the per-instance __dict__ is pure overhead. __slots__ removes it, shrinking each object and, as a side effect, rejecting stray attributes that would otherwise slip through as silent bugs.
Your task: Define a small record class that declares __slots__ for its fields, and show that assigning an attribute not in the slots raises — the observable signal that slots are active.
Requirements:
- Declare
__slots__as a tuple of the allowed field names - Set those fields in
__init__and read them back normally - Assigning an undeclared attribute raises
AttributeError - Catch that error and report it rather than crashing
- No per-instance
__dict__exists once slots are declared
💡 Hint: The rejection is the proof — wrap the illegal assignment in try/except AttributeError and print the message to show slots are enforcing the field set.
Show solution
__slots__ drops the per-instance __dict__, saving memory at scale. Runnable:
class LogRecord:
__slots__ = ("svc", "level", "msg")
def __init__(self, svc, level, msg):
self.svc, self.level, self.msg = svc, level, msg
r = LogRecord("checkout-api", "ERROR", "auth failed")
print(r.svc, r.level)
try:
r.extra = "nope" # not in __slots__
except AttributeError as e:
print("blocked:", e)
Context: Any code that opens a resource — a connection, a file, a lock — must guarantee it gets closed, even when the body raises. A class-based context manager encodes that setup/teardown contract so cleanup is automatic and exceptions still propagate correctly.
Your task: Write a class-based context manager that "opens" a resource in __enter__ and guarantees "close" in __exit__ even when the with body raises an exception.
Requirements:
__enter__marks the resource open and returns it foras__exit__always runs the close, whether or not an error occurred__exit__returns falsy so it does not swallow the exception- Demonstrate a body that raises and confirm the resource is still closed
- The raised error is caught outside the
with, not hidden by it
💡 Hint: Returning False (or nothing) from __exit__ lets the exception re-raise after cleanup; put the close in __exit__'s body so it fires on every exit path.
Show solution
Deterministic cleanup, the with-statement contract. Runnable:
class Connection:
def __init__(self, name): self.name = name; self.open = False
def __enter__(self):
self.open = True
print(f"opened {self.name}")
return self
def __exit__(self, exc_type, exc, tb):
self.open = False
print(f"closed {self.name}")
return False # don't swallow exceptions
try:
with Connection("db") as c:
print("using", c.name)
raise RuntimeError("boom")
except RuntimeError:
print("error handled; connection still closed")
Context: Calling a rate-limited model API in a burst gets you throttled; a token-bucket limiter smooths that out by refilling tokens at a steady rate and allowing short bursts up to a cap. It is the canonical client-side throttle, and it must be thread-safe because agents often fan out across threads.
Your task: Implement a thread-safe token-bucket RateLimiter(rate, burst) whose acquire consumes a token when one is available, then fire several rapid acquisitions and count how many were granted immediately.
Requirements:
- Refill tokens based on elapsed time ×
rate, capped atburst - Use a monotonic clock so time going backwards can't corrupt the bucket
- Guard the token state with a
threading.Lock - Grant a call only when at least one token is available, decrementing on success
- A rapid run of acquisitions grants roughly
burstof them immediately
💡 Hint: On each attempt, first add (now − last) × rate tokens clamped to the capacity, then spend one if you can — hold the lock across that whole read-modify-write.
Show solution
The production rate-limiter, made deterministic for the demo (no real sleeping). Runnable:
import time, threading
class RateLimiter:
def __init__(self, rate, burst):
self.rate, self.capacity = rate, burst
self.tokens = burst
self.updated = time.monotonic()
self.lock = threading.Lock()
def try_acquire(self):
with self.lock:
now = time.monotonic()
self.tokens = min(self.capacity,
self.tokens + (now - self.updated) * self.rate)
self.updated = now
if self.tokens >= 1:
self.tokens -= 1
return True
return False
lim = RateLimiter(rate=10, burst=3)
granted = sum(lim.try_acquire() for _ in range(10))
print("immediately granted:", granted) # ~3 (the burst)
Context: When a downstream dependency is failing, retrying it just piles on load and slows everything upstream. A circuit breaker trips after N consecutive failures and refuses calls while open, giving the dependency room to recover — a core resilience primitive in any distributed agent system.
Your task: Build a circuit breaker that opens after a threshold of consecutive failures and refuses further calls while open, then drive it with a function that always fails and show it trips.
Requirements:
- Count consecutive failures and open the circuit once the threshold is hit
- While open, a call short-circuits with a dedicated
CircuitOpenexception instead of invoking the function - A success resets the failure count back to zero
- The wrapped function's own exceptions still propagate on the way to tripping
- Driving it with an always-failing function trips it after exactly the threshold count
💡 Hint: Track failures on the instance; in call, check the open flag first, then run the function under try/except, incrementing on failure and flipping open when the count reaches the threshold.
Show solution
Stops hammering a failing dependency. Runnable (stdlib):
class CircuitOpen(Exception): pass
class CircuitBreaker:
def __init__(self, threshold=3):
self.threshold = threshold
self.failures = 0
self.open = False
def call(self, fn, *args, **kwargs):
if self.open:
raise CircuitOpen("circuit is open; refusing call")
try:
result = fn(*args, **kwargs)
except Exception:
self.failures += 1
if self.failures >= self.threshold:
self.open = True
raise
self.failures = 0
return result
def always_fails(): raise ValueError("down")
cb = CircuitBreaker(threshold=3)
for i in range(5):
try:
cb.call(always_fails)
except CircuitOpen as e:
print(i, "->", e); break
except ValueError:
print(i, "-> failed")
Context: A production agent runner is the sum of this whole page: it reads config, throttles with a rate limiter, guards each task with a circuit breaker, logs structurally, and returns a summary the caller can act on. Wiring these together resiliently — so one bad task doesn't take down the batch — is the real deliverable.
Your task: Combine config, a rate limiter, a circuit breaker, and structured logging into a run(tasks) that processes tasks resiliently and returns a summary of what succeeded and failed, then prove its behaviour with an assertion.
Requirements:
- Each task is executed through the circuit breaker from the previous rung
- A tripped breaker stops the run early with a logged warning rather than continuing
- Task-level failures are counted and logged, not allowed to abort the whole batch
- Use the
loggingmodule for structured output, not bare prints - Return a summary dict (e.g. completed results and a failure count)
- Assert the summary equals the expected value for a known input
💡 Hint: Reuse the breaker you built, loop the tasks catching CircuitOpen (break) separately from a task's own error (count and continue), and accumulate results into the summary you return.
Show solution
The capstone runner shape — each task guarded by the breaker, throttled by the limiter, all logged. Runnable (stdlib), reusing the components above:
import logging
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("runner")
class CircuitOpen(Exception): pass
class Breaker:
def __init__(self, threshold=3): self.t=threshold; self.f=0; self.open=False
def call(self, fn, x):
if self.open: raise CircuitOpen()
try:
r = fn(x); self.f = 0; return r
except Exception:
self.f += 1
if self.f >= self.t: self.open = True
raise
def process(x):
if x < 0: raise ValueError("bad task")
return x * 2
def run(tasks):
br = Breaker(threshold=2)
done, failed = [], 0
for t in tasks:
try:
done.append(br.call(process, t))
except CircuitOpen:
log.warning("circuit open, stopping"); break
except ValueError:
failed += 1; log.error("task %s failed", t)
return {"done": done, "failed": failed}
summary = run([1, 2, 3])
assert summary == {"done": [2, 4, 6], "failed": 0}
print(summary)
✓ Checkpoint — you can engineer agents professionally when you can…
- Process large data with generators & know when
__slots__helps. - Reuse clients & manage resources with context managers /
ExitStack. - Implement a rate limiter and a circuit breaker, and say when each fires.
- Load typed settings and emit structured logs with trace ids.
- Write a real pytest suite (fixtures, parametrize, markers) splitting fast tests from evals.
- Package your agent as an installable CLI and run ruff/mypy/pytest in CI.
- Read the capstone runner and name every production pattern in it.
print() to a production-shaped, tested, packaged agent runner. Combined with the course chapters and the six projects, you now have everything to build and ship real agentic systems. Go build one.Knowledge check check yourself
Contrast the circuit breaker with the token-bucket rate limiter as described in this lesson: what failure does each address, and when does each 'fire'?
Show answer
burst then throttling to rate. The circuit breaker protects against a failing dependency — it trips open after N consecutive failures and fails fast during a cooldown so you stop hammering a dead backend, resetting on a success.Why does the lesson insist on creating one module-level Anthropic() client instead of one per request, and how do @contextmanager/ExitStack support correct resource handling?
Show answer
@contextmanager with try/finally guarantees cleanup (e.g. con.close()) even if the body raises, and ExitStack cleanly manages a dynamic, unknown number of resources, closing them all when the block exits.