AI EngineeringZero to ProductionHome·About·What’s new·Contact
Appendix · Python for AI Agents · Part 6

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.

⏱️ ~2.5 hours🎯 Expert · production🏭 real engineeringrunnable
⚙️ To run this for realThe code here is complete and correct as written. To actually execute it you'll need an API key for whichever vendor you follow:Either way, keep the key in a .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.

same 12 GB file, two strategies — memory is the difference ✗ readlines() — load everything file list in RAM holds ALL lines → 12 GB allocation → OOM kill ✓ generator — stream one line file read yield line parse split filter ERROR? one result memory stays flat — a few KB, whatever the file size Stream, don't slurp. 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.
🗺️ How to read this diagram

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 file into 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.

Example code for learning — review, test, and adapt it before running against real or production systems. Commands can create, change, or delete resources. See the Terms & Disclaimer.
Try it

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
▶ How this works

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).

  1. open(path) inside a with block 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.
  2. yield line is what makes error_lines a generator. Instead of building a whole list and returning it, yield produces one value, pauses, and resumes on the next request. Nothing is computed until someone asks for it.
  3. parse is 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.
  4. islice(..., 10) pulls just the first 10 results, so the pipeline reads only as much of the file as it needs and then stops.
  5. __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.

Measure, don't guessBefore optimizing, profile: 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.
🔗 Course relevanceGenerators are how you'd process the doc-intel project's invoice folder or the DevOps agent's log streams without loading everything. __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.

the same three requests, two client strategies ✗ new client per request req 1 req 2 req 3 new pool new pool new pool → FDs run out → “Too many open files” ✓ one shared client req 1 req 2 req 3 one warm pool module-level client → reused → stable under load Reuse the client; don't recreate it. A fresh 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.
🗺️ How to read this diagram

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.

Try it

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
▶ How this works

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.

  1. bad_handler creates a new Anthropic() 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.
  2. The @contextmanager decorator turns readonly_db into something you can use with with. The code before yield is setup (open the connection); the code after is teardown (close it).
  3. The try / finally is the whole point: finally runs no matter what — even if your query raises an error — so con.close() always happens and the connection is never leaked.
  4. with readonly_db("shop.db") as con: is how you use it. When the with block ends (normally or via an exception), Python automatically runs the teardown for you.
  5. ExitStack handles the case where you don't know how many resources you'll open. Every file added with stack.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().

🔗 Course relevanceThe data-analyst project's 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.

a circuit breaker is a three-state machine CLOSED calls flow OPEN fail fast HALF-OPEN one trial call N fails cooldown ends trial succeeds → reset trial fails → trip again success resets count The breaker's whole value is the loop back to CLOSED. It trips OPEN after N consecutive failures and fails fast for the cooldown; when the cooldown ends it lets one HALF-OPEN trial call through — success closes it and normal service resumes, failure trips it straight back open.
🗺️ How to read this diagram

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.

Try it — token-bucket rate limiter
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(...)
▶ How this works

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).

  1. __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).
  2. self.lock = threading.Lock() makes the limiter thread-safe: if several threads call at once, the with self.lock: block lets only one adjust the token count at a time, so two threads can't both think a token is free.
  3. Every acquire() first refills: (now - self.updated) * self.rate is how many tokens accumulated since last time. min(self.capacity, ...) caps it so the bucket never overflows past burst.
  4. 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.

Try it — circuit breaker
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
▶ How this works

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).

  1. State lives in two fields: self.failures counts consecutive failures, and self.opened_at records when the breaker tripped (or None if it's healthy).
  2. 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.
  3. It then tries to run fn. On an exception it bumps self.failures; once that reaches threshold, it sets self.opened_at to now (trips open) and re-raises the error.
  4. On success the last line resets self.failures to 0 and clears opened_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.

Why a breaker matters for agentsIf a tool's backend (a database, the GitHub API, a monitoring endpoint) goes down, retrying every call just piles up latency and cost. A breaker fails fast after N failures, gives the dependency time to recover, then retries. Combined with retry+backoff (P4) and the rate limiter above, you have the full resilience stack the Ch 6 "circuit breaker / graceful degradation" section describes.
🔗 Course relevanceThe rate limiter is the "token-bucket limiter" named in Ch 6 scaling and the DevOps/analyst projects (respect API + DB limits). The breaker protects the agent when a tool's backend is down — degrade gracefully instead of hanging.

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.

config: fail late in a request, or fail loud at boot ✗ scattered os.environ.get missing key typo in env returns None travels on quietly → cryptic TypeError mid-request ✓ typed Settings at boot missing key typo in env validate once typed + checked → refuses to launch, names field logging: a sentence you scroll past, or a record you query print(f"{user} took {ms}ms") free text — cannot filter or aggregate {"msg":"llm_call","tokens":120} JSON — a dashboard can query it Fail loud at boot; log in JSON. A scattered 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.
🗺️ How to read this diagram

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 cryptic TypeError deep inside a live request. The top-right green path is a typed Settings object 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.

Try it — typed settings
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
▶ How this works

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.

  1. The class inherits from BaseSettings. The model_config line says: load from a file named .env, and every env var is prefixed with AGENT_ (so anthropic_api_key comes from AGENT_ANTHROPIC_API_KEY).
  2. Each attribute is a setting with a type. anthropic_api_key: str has no default, so it's required — if it's missing, creating the object errors immediately instead of failing confusingly mid-request later.
  3. The others have defaults (model, max_tokens = 1024, confidence_threshold = 0.6). Pydantic also coerces types: an env var is always text, but max_tokens: int means the string "1024" is turned into the number 1024 for you.
  4. settings = Settings() runs the validation once, at import time. After that, settings.model and 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.

Try it — structured logging
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}})
▶ How this works

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.

  1. JsonFormatter subclasses Python's built-in logging.Formatter and overrides format. Whatever this method returns is the text of one log line — here, a JSON string built with json.dumps(...).
  2. 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 a trace_id or token counts.
  3. The handler decides 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.
  4. 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.

🔗 Course relevanceTyped 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.

one suite, two speeds — split on whether the test calls the model your test suite unit tests — mocked client fixtures + parametrize · no key · ms eval tests — mark.eval calls Claude · needs key · slow pytest -m "not eval" · CI pytest -m eval · PRs / nightly Split fast tests from evals. Most logic — retrieval, SQL guards, parsing, routing — is tested against a mocked client, deterministically and free, on every commit; the handful of tests that must call the model are tagged @pytest.mark.eval and run on PRs or nightly, so the default suite stays fast and green.
🗺️ How to read this diagram

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.eval that 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), while pytest -m eval runs 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.

Try it

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
▶ How this works

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.

  1. A fixture (@pytest.fixture on mock_client) is reusable setup. Any test that takes an argument named mock_client automatically receives what the fixture returns. Here it builds a MagicMock — a fake Anthropic client that returns a canned reply, so tests never hit the real API.
  2. 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).
  3. with pytest.raises(ValueError, match="blocked"): asserts that the code inside must throw a ValueError whose message contains "blocked". This is how you test that bad input is correctly rejected.
  4. Markers label tests. @pytest.mark.asyncio lets an async test run; @pytest.mark.eval tags the slow, API-key-requiring tests so you can run or skip them as a group. The terminal block 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.

The pro test strategy for agentsMark key-free logic tests to run on every commit (fast, free), and mark model-calling evals as @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.
🔗 Course relevanceEvery project ships a 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.

packaging turns a location into a name folder of scripts only runs from repo root pyproject.toml [project.scripts] entry pip install -e register command runs anywhere devops-agent = "devops_agent.cli:main" the entry point maps the command name → module:function -e (editable) points the install at your source — edit and re-run, no reinstall From a path to a command. A bare folder of scripts only runs from the right directory; 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.
🗺️ How to read this diagram

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.toml declares a [project.scripts] entry point, and pip install -e . registers the project with Python. The line below spells out the entry point — the module:function form 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.

Try it
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
▶ How this works

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].

  1. [project] declares the package's identity: its name (what you'd pip install), version, the Python it needs (requires-python), and its dependencies — the other libraries pip should install alongside it.
  2. [project.scripts] is the magic that creates a command. The line devops-agent = "devops_agent.cli:main" means: when someone types devops-agent in the terminal, run the main function in the devops_agent/cli.py file.
  3. [build-system] tells pip which tool to use to actually build the package (here hatchling). You rarely touch this — it's boilerplate that makes the install work.
  4. The terminal block shows the payoff: pip install -e . installs your project in editable mode (code changes take effect without reinstalling), and afterwards devops-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.

A clean CLI with argparseThe projects already have 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.
🔗 Course relevanceThe DevOps and analyst projects have 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.

every change passes the same gate — layered checks, run by pre-commit change git commit ruff format + lint mypy types pytest -m "not eval" merge style slips wrong types broken behaviour any check fails → commit blocked, nothing merges The tools layer, they don't overlap. A change runs the gauntlet — 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.
🗺️ How to read this diagram

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 → merge only if every stage is satisfied.
  • Each box guards a different class of mistake, labelled below it: ruff catches style slips and unused imports, mypy catches wrong types without running the code, and pytest -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-commit is 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.

ToolDoesCommand
ruffLint + format (replaces flake8/black/isort) — extremely fastruff check . && ruff format .
mypy / pyrightStatic type checking — enforces the P5 §1 typesmypy agent/
pytest + pytest-covTests + coveragepytest --cov
pre-commitRun all the above automatically before each git commitpre-commit run --all
uvFast dependency & venv manager (Rust-based)uv pip install -r requirements.txt
Minimum viable quality gateFor any agent you'll maintain: 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.

one ask() call, threaded through seven cooperating patterns ask(prompt) breaker open? → fail fast rate limit — spend a token call model (retry loop) success → reset + log "ok" fail → count, backoff, retry at threshold → trip breaker ok error loop Every request rides the whole stack. A single 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.
🗺️ How to read this diagram

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.

Capstone example
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)
▶ How this works

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.

  1. Setup (top): client = Anthropic() is created once and reused (§2). trace_id is a contextvars.ContextVar — a per-request id so every log line from one request can be tied together. AgentError is a custom exception type.
  2. _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.
  3. ask (the circuit breaker + retry loop): first it fails fast if the breaker is open (§3). Then it loops up to max_retries times: rate-limit, call the model, and on success reset the breaker and log a structured "ok" line with timing and token counts.
  4. On failure: it counts the failure (tripping the breaker at the threshold), computes a 2**attempt + random jittered backoff delay (P4), logs a "retry" line, and sleeps before trying again. If all retries are exhausted it raises AgentError.
  5. handle: the entry point sets a fresh trace_id per request (so logs are traceable) and delegates to Runner().ask(...). Swap the body of ask for 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.

Read it as a checklistThis one class demonstrates: reused client (§2), token-bucket rate limiting (§3), a circuit breaker (§3), retry with jittered backoff (P4), custom exceptions (P4), structured logging with trace ids (§4 + P5), and request-scoped context (P5). That's the production skeleton behind any of the six projects — swap ask()'s body for the project's agent loop.

The complete Python journey expert

PartLevelYou can now…
P1Beginnerread & write any lab
P2Beginner→Int.handle messages, chunks, JSON, logs
P3Intermediatestructure the agent: modules, OOP, dataclasses, enums
P4Int.→AdvancedPydantic, exceptions, streaming, async, secrets
P5Experttyping, functools/itertools, concurrency, testing the model
P6 (here)Productionscale, 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.

Implement a token-bucket rate limiter

"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
▶ How this works

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.

  1. __init__ stores rate (tokens per per seconds), starts the bucket full (self.tokens = rate), and records the current time with time.monotonic().
  2. In allow(), the refill formula (now - self.updated) * self.rate / self.per adds tokens for the time that has passed, and min(self.rate, ...) caps the bucket so it can't overfill.
  3. If at least one token is available, it spends one (self.tokens -= 1) and returns True (call allowed); otherwise it returns False (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.

Circuit breaker — stop hammering a dead dependency

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
▶ How this works

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.

  1. __init__ tracks a failure count (self.fails), the trip threshold, and a boolean self.open that starts False (closed / healthy).
  2. call(self, fn) first checks: if self.open is true, raise "circuit open" immediately — the fail-fast behaviour that stops hammering a dead dependency.
  3. It runs fn(); on an exception it increments self.fails, and once that hits threshold it flips self.open = True (trips) and re-raises.
  4. On success it resets self.fails = 0 and 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.

Exercise 1 · Stream a big file with a generatorBeginner

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 ERROR are 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
Exercise 2 · Shrink objects with __slots__Intermediate

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)
Exercise 3 · Manage a resource with a context managerAdvanced

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 for as
  • __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")
Exercise 4 · A token-bucket rate limiterExpert

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 at burst
  • 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 burst of 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)
Exercise 5 · A circuit breakerProfessional

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 CircuitOpen exception 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")
Exercise 6 · A production-shaped agent runnerIndustry scenario

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 logging module 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.
🎓 You've completed the full Python track (P1–P6)From your first 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

✓ Knowledge check

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
The rate limiter (token bucket) keeps you under your calls-per-second quota — it refills tokens over time and sleeps/denies when the bucket is empty, allowing bursts up to 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.
✓ Knowledge check

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
Creating a client per request opens a new connection pool each time, which leaks connections and is slow under load; a single reused module-level client pools connections. A @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.
© 2026 studybydoing.in · AI Engineering: Zero to Production · All rights reserved. · About · Privacy Policy · Terms · Contact
Educational content, provided as-is and without warranty. Code samples are examples — review, test, and adapt them before using in production. See the Terms of Use & Disclaimer. Use at your own risk.
© studybydoing.in