AI EngineeringZero to ProductionHome·About·Contact
Appendix · Advanced AI Engineering · Part 6

Validation, Structured Outputs & Resiliency

LLMs are non-deterministic and networks fail — so the boundary around your model must be strict on the way in and resilient on the way out. This part covers Pydantic mastery for bulletproof structured outputs, then the resiliency patterns every production agent needs: an error taxonomy, retry-with-backoff, circuit breakers, timeouts, idempotency, and fallbacks.

⏱️ ~2 hours🎯 Advanced → Expert🛡️ trust boundariesrunnable

Learning objectives

  • Model data with Pydantic: types, constraints, defaults, nested models.
  • Write field/model validators and understand coercion vs strict mode.
  • Use discriminated unions to parse "one of several shapes" safely (e.g. tool calls).
  • Force schema-valid LLM output and validate it, retrying on failure.
  • Classify errors (retryable vs fatal) and apply retry + exponential backoff + jitter.
  • Implement a circuit breaker, timeouts, idempotency keys, and graceful fallbacks.

Why the boundary matters motivation

An agent takes untrusted input (users, tools, model output) and drives real actions. If a malformed tool argument or a hallucinated field slips through, you get wrong actions or crashes. If a transient 429/503 isn't retried, a whole batch fails. Validation keeps bad data out; resiliency keeps transient failures from becoming outages. Together they're what makes an agent trustworthy.

1 · Pydantic foundations intermediate → advanced

Pydantic parses and validates data against typed models — the de-facto standard for LLM structured output, config, and API boundaries. Define the shape once; get validation, coercion, and clear errors for free.

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 — a validated model
pythonfrom pydantic import BaseModel, Field
from typing import Literal

class Diagnosis(BaseModel):
    summary: str = Field(min_length=1, max_length=500)
    severity: Literal["low", "medium", "high", "critical"]   # enum-constrained
    confidence: float = Field(ge=0.0, le=1.0)                    # 0..1
    affected: list[str] = []                                     # default empty

d = Diagnosis(summary="pod crash-looping", severity="high", confidence=0.9)
print(d.model_dump())            # dict, ready to serialize
# Diagnosis(summary="", severity="bad", confidence=2)  -> ValidationError with
# precise messages for EACH bad field at once
▶ How this works

This defines the shape of a piece of data once, and Pydantic then checks any incoming values against it for free. A BaseModel subclass is like a form with typed, rule-checked fields — perfect for the messy JSON an LLM hands back.

  1. Each line inside the class is a field: a name, its type, and optional rules. summary: str = Field(min_length=1, max_length=500) says "a string, 1–500 characters".
  2. severity: Literal["low", "medium", "high", "critical"] restricts the value to exactly those four words — anything else is rejected. confidence must be a number between 0 and 1 (ge=0.0, le=1.0 = greater-or-equal / less-or-equal).
  3. affected: list[str] = [] gives a default (an empty list), so the field is optional.
  4. Creating Diagnosis(...) runs all the checks at once. d.model_dump() turns the validated object back into a plain dictionary you can send over the wire.

What the output means: A clean dictionary of the fields. The commented-out bad example would instead raise a ValidationError that names every bad field at once (empty summary, invalid severity, confidence out of range) — not just the first.

Try this: Change severity="high" to "urgent" and run it. Pydantic rejects it and tells you the allowed values — that rejection is the whole point: bad data never gets past the boundary.

🔗 Used in the courseThis is the structured-output backbone from Ch 2 and the schemas across every project (support answer, DevOps diagnosis, analyst query). P4/P5 introduce Pydantic; this page is the production-depth pass.

2 · Validators & coercion advanced

Beyond types, you often need custom rules and cross-field checks. Field validators clean/validate one field; model validators check relationships between fields. Pydantic coerces by default ("3"3); use strict mode when you don't want that.

Try it — field & model validators
pythonfrom pydantic import BaseModel, field_validator, model_validator

class ScaleAction(BaseModel):
    service: str
    replicas: int
    max_replicas: int = 10

    @field_validator("service")
    @classmethod
    def normalize(cls, v):
        return v.strip().lower()            # clean the value

    @field_validator("replicas")
    @classmethod
    def non_negative(cls, v):
        if v < 0:
            raise ValueError("replicas cannot be negative")
        return v

    @model_validator(mode="after")
    def within_limit(self):
        if self.replicas > self.max_replicas:   # cross-field rule
            raise ValueError("replicas exceeds max_replicas")
        return self

print(ScaleAction(service="  Web ", replicas=3).service)   # 'web'
▶ How this works

Types alone can't express every rule. Validators are small methods you attach to a model to clean values or enforce custom logic. A field validator checks one field; a model validator checks how fields relate to each other.

  1. The @field_validator("service") method runs on the service value and return v.strip().lower() normalizes it — trimming spaces and lowercasing — so " Web " becomes "web". Whatever you return replaces the stored value.
  2. The non_negative validator raises a ValueError when replicas is below zero. Raising inside a validator is how you reject bad input.
  3. @model_validator(mode="after") runs once all fields exist, so it can compare them: here it checks replicas against max_replicas — a cross-field rule a single field couldn't do alone.

What the output means: The print shows 'web' — the raw " Web " after the field validator cleaned it. Passing replicas=99 would trip the model validator and raise "replicas exceeds max_replicas".

Try this: Call ScaleAction(service="api", replicas=-1) and read the error. Then try replicas=50 to see the cross-field check fire instead.

3 · Discriminated unions — parse "one of several shapes" expert intermediate

A tool-calling agent receives actions that could be any of several types, each with different fields. A discriminated union uses a tag field to tell Pydantic which model to validate against — turning a messy "figure out what this is" into a typed, exhaustive dispatch.

Try it — typed action dispatch
pythonfrom pydantic import BaseModel, Field, TypeAdapter
from typing import Literal, Union, Annotated

class Scale(BaseModel):
    action: Literal["scale"] = "scale"       # the discriminator
    service: str
    replicas: int

class Restart(BaseModel):
    action: Literal["restart"] = "restart"
    service: str

class Rollback(BaseModel):
    action: Literal["rollback"] = "rollback"
    service: str
    to_version: str

Action = Annotated[Union[Scale, Restart, Rollback], Field(discriminator="action")]
adapter = TypeAdapter(Action)

a = adapter.validate_python({"action": "rollback", "service": "api", "to_version": "v1.2"})
print(type(a).__name__, a.to_version)      # Rollback v1.2
# wrong shape for the tag -> ValidationError naming the missing field
▶ How this works

An agent's tool call could be any of several shapes (scale, restart, rollback), each with different fields. A discriminated union uses one tag field to tell Pydantic which model to validate against — so "figure out what this is" becomes a clean, typed dispatch.

  1. Each class has an action field pinned to a single literal value (e.g. Literal["scale"]). That field is the discriminator — the tag Pydantic reads first to pick the right model.
  2. Action = Annotated[Union[Scale, Restart, Rollback], Field(discriminator="action")] bundles the three shapes and tells Pydantic: look at action to decide which one.
  3. TypeAdapter(Action) wraps that union so you can validate raw data against it. adapter.validate_python({...}) reads "action": "rollback" and validates the dict as a Rollback, requiring to_version.

What the output means: Rollback v1.2 — the dict was parsed into the correct typed object. A dict whose fields don't match its tag (e.g. a "rollback" missing to_version) raises a ValidationError naming the missing field.

Try this: Feed it {"action": "scale", "service": "api", "replicas": 3} and check type(a).__name__ is now Scale. This one pattern is how you turn LLM JSON into safe, typed actions.

Why discriminated unions are perfect for agentsAn LLM emits a tool call as JSON; a discriminated union validates it into the right typed object in one step, rejecting malformed calls before they touch your executor. Combined with the safety gate (Ch 8), every action is both type-valid and policy-checked before it runs.

4 · Structured LLM outputs — the reliable pattern advanced

Don't parse prose. Ask the model for structured output bound to your schema, and validate the result — retrying with the validation error fed back if it doesn't conform. This is the single biggest reliability win for agents.

Try it — validate-and-retry wrapper
pythonfrom pydantic import BaseModel, ValidationError

# Preferred: the SDK parses INTO your model for you —
#   resp = client.messages.parse(model="claude-opus-4-8",
#             messages=[...], output_format=Diagnosis)
#   diagnosis = resp   # already a validated Diagnosis
#
# When you must validate raw text yourself, retry feeding back the error:
def parse_with_retry(call_model, schema, prompt, tries=3):
    err = None
    for _ in range(tries):
        raw = call_model(prompt if err is None
                         else f"{prompt}\n\nYour last output failed validation: {err}\nReturn valid JSON.")
        try:
            return schema.model_validate_json(raw)    # success
        except ValidationError as e:
            err = str(e)                              # feed the error back to the model
    raise ValueError(f"model never produced valid output: {err}")
▶ How this works

The most reliable way to get structured data from a model is to bind it to a schema and validate the result. If validation fails, you feed the error back and ask again. This wrapper does that retry loop by hand for cases where the SDK can't do it for you.

  1. The commented-out top shows the preferred path: client.messages.parse(..., output_format=Diagnosis) lets the SDK parse straight into your model. Use that when you can.
  2. parse_with_retry loops up to tries times. On the first pass it sends the plain prompt; on later passes it appends the previous error so the model can correct itself.
  3. schema.model_validate_json(raw) tries to validate the model's raw text. If it works, we return immediately — success.
  4. If it raises ValidationError, we save the message in err and loop. After all tries fail, we raise ValueError so the caller knows the model never complied.

What the output means: On success you get a fully validated schema object. Feeding the error back is the key trick — the model usually fixes its own mistake on the second attempt.

Try this: Write a fake call_model that returns broken JSON once, then valid JSON, and confirm parse_with_retry succeeds on the second try.

Prefer the SDK's native structured outputThe Anthropic SDK's messages.parse() with an output_format (a Pydantic model) validates the response for you and retries at the tool-call layer — use it instead of hand-rolling parse-and-retry when you can. Hand-rolling is the fallback for providers/paths without native support. Either way: never trust unvalidated model text as data.

5 · An error taxonomy — retryable vs fatal advanced

Resiliency starts with classification: some errors are transient (retry helps), others are permanent (retrying just wastes time and money). Decide per error type.

CategoryExamplesAction
Transient429 rate limit, 503, timeout, connection resetRetry with backoff
Client/permanent400 bad request, 401 auth, 404, validation errorFail fast — fix the request
Overload (server)529 overloadedRetry with longer backoff / fallback model
Refusal / safetycontent declinedHandle explicitly (fallback / surface to user)
Try it — a small classifier
pythonclass Retryable(Exception): ...
class Fatal(Exception): ...

def classify(status):
    if status in (429, 500, 502, 503, 529):
        raise Retryable(f"transient {status}")
    if 400 <= status < 500:
        raise Fatal(f"client error {status} — do not retry")
▶ How this works

Resiliency starts by sorting errors into buckets. Some are transient (retrying helps); others are permanent (retrying just wastes time and money). This tiny classifier turns an HTTP status code into one of two custom exceptions so the rest of your code knows what to do.

  1. Retryable and Fatal are custom exception classes (the ... body just means "empty"). Their type carries the decision.
  2. If status is one of the transient codes (429 rate-limit, 500/502/503, 529 overloaded), it raises Retryable — a signal that backing off and trying again may work.
  3. The check if 400 <= status < 500 catches the other 4xx client errors (bad request, auth, not-found) and raises Fatal — retrying can't fix a broken request, so fail fast.

What the output means: Nothing prints; the point is which exception type comes out. Downstream code catches Retryable to back off and re-try, and lets Fatal bubble up unchanged.

Try this: Call classify(429) and classify(404) and note how the exception type changes. That type is what the retry loop in the next section keys off of.

6 · Retry with exponential backoff + jitter expert advanced

Retry only retryable errors, and space attempts out exponentially (1s, 2s, 4s…) with jitter (randomness) so many clients don't retry in lockstep and hammer a recovering server (the "thundering herd").

Try it — production retry
Setup to run this snippet
class _Any:
    '''stands in for any undefined demo value; supports call/attr/index/
    iteration and basic arithmetic (as 0.7) so demo snippets run.'''
    def __call__(self, *a, **k): return _Any()
    def __getattr__(self, k): return _Any()
    def __getitem__(self, k): return _Any()
    def __iter__(self): return iter([])
    def __len__(self): return 0
    def __contains__(self, o): return True
    def __enter__(self, *a): return _Any()
    def __exit__(self, *a): return False
    def __float__(self): return 0.7
    def __int__(self): return 1
    def __lt__(self, o): return True
    def __gt__(self, o): return False
    def __le__(self, o): return True
    def __ge__(self, o): return False
    def __add__(self, o): return o
    def __radd__(self, o): return o
    def __bool__(self): return True
    def __repr__(self): return 'demo'
    def __str__(self): return 'demo'
Fatal = _Any()
Retryable = _Any()
pythonimport random, time

def retry(fn, tries=5, base=1.0, cap=30.0):
    for attempt in range(tries):
        try:
            return fn()
        except Fatal:
            raise                               # never retry permanent errors
        except Retryable:
            if attempt == tries - 1:
                raise
            # exponential backoff with full jitter
            delay = min(cap, base * (2 ** attempt))
            time.sleep(random.uniform(0, delay))    # jitter avoids thundering herd
▶ How this works

This is the production retry loop. It re-tries only errors marked Retryable, and it spaces attempts out exponentially (1s, 2s, 4s…) with jitter (randomness) so a crowd of clients doesn't all retry at the same instant and re-crush a recovering server.

  1. The for attempt in range(tries) loop tries fn() and returns its result the moment it succeeds.
  2. except Fatal: raise re-throws permanent errors immediately — never waste retries on something that can't succeed.
  3. except Retryable handles the transient case. If this was the last attempt (attempt == tries - 1) it gives up and re-raises; otherwise it waits and loops.
  4. delay = min(cap, base * (2 ** attempt)) doubles the wait each round but caps it. time.sleep(random.uniform(0, delay)) picks a random point up to that delay — that randomness is the jitter that prevents the "thundering herd".

What the output means: No visible output on its own; it either returns fn()'s result or, after enough transient failures, re-raises the last error. The pauses grow and are randomized between runs.

Try this: Set base=0.1 and print attempt each loop while raising Retryable, to watch the backoff grow. Real code should also honor a Retry-After header when the server sends one.

The SDK already does this — but know itThe Anthropic SDK retries transient errors with backoff automatically. You still need these patterns for your own tools (DB calls, internal APIs, webhooks) and to tune max_retries/timeouts. Honor Retry-After headers when present.

7 · Circuit breaker — stop hammering a dead dependency expert advanced

If a downstream service is down, retrying every call just piles on load and slows your system. A circuit breaker "opens" after N consecutive failures — failing fast for a cooldown — then "half-opens" to test recovery before resuming.

Try it — a circuit breaker
pythonclass CircuitBreaker:
    def __init__(self, threshold=5, cooldown=30.0, now=None):
        self.threshold, self.cooldown = threshold, cooldown
        self.failures = 0
        self.opened_at = None
        self._now = now or (lambda: __import__("time").monotonic())

    def call(self, fn):
        if self.opened_at is not None:
            if self._now() - self.opened_at < self.cooldown:
                raise RuntimeError("circuit OPEN — failing fast")
            self.opened_at = None            # half-open: allow one trial
        try:
            result = fn()
        except Exception:
            self.failures += 1
            if self.failures >= self.threshold:
                self.opened_at = self._now()   # trip the breaker
            raise
        self.failures = 0                    # success resets
        return result
▶ How this works

When a downstream service is truly down, even smart retries pile on load. A circuit breaker watches for consecutive failures and, after too many, "opens" — failing instantly for a cooldown instead of calling the dead service. Then it cautiously tests recovery.

  1. threshold is how many failures in a row trip the breaker; cooldown is how long it stays open. opened_at records when it tripped (None means closed/healthy).
  2. In call(), if the breaker is open and still within the cooldown, it raises immediately — "circuit OPEN — failing fast" — without touching the service.
  3. Once the cooldown passes it clears opened_at to go half-open: it allows one trial call to see if the service is back.
  4. On any exception it bumps self.failures and trips the breaker once the threshold is hit. On success it resets failures to 0 — the service is healthy again.

What the output means: No output by itself. In use, the first few failures pass through and count up; after threshold failures, further calls fail instantly for cooldown seconds — protecting both you and the struggling dependency.

Try this: Create CircuitBreaker(threshold=2) and call it with a function that always raises. After 2 failures the next call should raise "circuit OPEN" instead of running your function.

🔗 Used in the courseThe rate limiter + circuit breaker pair appears in P6's resilience section and the capstone runner. In the DevOps agent, a breaker around a flaky cluster API prevents a storm of failing calls during an incident.

8 · Idempotency & graceful fallbacks advanced

When you retry an action that changes state, you risk doing it twice (double-charge, double-deploy). An idempotency key makes repeats safe: the server (or your own dedup layer) recognizes the key and applies the operation once. And when a call ultimately fails, a fallback keeps the system useful.

Try it — idempotent execute + fallback
python_done = {}                                # idempotency store (dict; a DB/Redis in prod)

def execute_once(key, action):
    if key in _done:                       # already ran -> return cached result
        return _done[key]
    result = action()                     # the real, state-changing call
    _done[key] = result
    return result

def with_fallback(primary, fallback):
    try:
        return primary()
    except Exception:
        return fallback()                # e.g. cheaper model, cached answer, "escalate"

# Agent example: try the frontier model; on refusal/overload, fall back.
# answer = with_fallback(lambda: ask_opus(q), lambda: cached_or_escalate(q))
▶ How this works

Retrying an action that changes state risks doing it twice (double-charge, double-deploy). An idempotency key makes a repeat safe — the operation runs once, and later calls with the same key return the stored result. A fallback keeps things useful when the primary path fails.

  1. _done is a dictionary standing in for a real dedup store (a DB or Redis in production). It remembers which keys already ran.
  2. execute_once(key, action) checks the store first: if the key is present it returns the cached result without re-running the action. Otherwise it runs the action once, saves the result under the key, and returns it.
  3. with_fallback(primary, fallback) tries primary() and, on any exception, quietly runs fallback() instead — e.g. a cheaper model, a cached answer, or an "escalate to a human" path.
  4. The commented agent example shows the pattern: try the frontier model, and on refusal or overload fall back to something safe.

What the output means: No print here. The behavior to picture: calling execute_once twice with the same key runs the real action only once; with_fallback never lets a single failure surface as a crash.

Try this: Call execute_once("deploy-42", do_deploy) twice with a do_deploy that prints — you'll see it print only once. That's idempotency protecting a state-changing tool.

The safety tie-inIdempotency is a correctness guardrail for the agent's state-changing tools (Ch 8): generate a key per intended action so a retry — from a network blip or a re-run — can't scale twice or deploy twice. This complements the policy gate (which decides whether an action may run) with a guarantee about how many times it runs.

Exercises expert

Practice
  1. Model a SupportAnswer with a confidence 0–1, a Literal action, and a validator that forces action="escalate" when confidence < 0.5.
  2. Build a discriminated union of 3 tool calls and a function that dispatches each to the right handler.
  3. Wrap parse_with_retry around a mock model that returns bad JSON twice then good JSON; assert it succeeds.
  4. Combine retry + CircuitBreaker: retries within a call, breaker across calls; test the breaker trips after N failures.
  5. Add idempotency to a mock "deploy" so calling it twice with the same key deploys once.

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

Reliable structured output from an LLM

Bind the response to a schema and validate; retry feeding the error back on failure.

pythonfrom pydantic import BaseModel, ValidationError
class Answer(BaseModel):
    summary: str
    confidence: float
def parse_with_retry(call, prompt, tries=3):
    err = None
    for _ in range(tries):
        raw = call(prompt if not err else f"{prompt}\nFix: {err}")
        try: return Answer.model_validate_json(raw)
        except ValidationError as e: err = str(e)
    raise ValueError("no valid output")
▶ How this works

The interview version of section 4, tightened up. The question is: how do you get reliable structured data out of an unreliable model? Answer: define a schema, validate the output, and retry feeding the error back — the pattern interviewers want to hear.

  1. Answer is a minimal Pydantic model — a summary string and a confidence number — the target shape you insist the model produce.
  2. parse_with_retry loops tries times. On retries it appends \nFix: {err} to the prompt so the model sees exactly what it got wrong.
  3. Answer.model_validate_json(raw) validates the raw text; success returns the typed object, failure is caught as ValidationError and stored in err for the next pass.
  4. After all attempts fail, it raises so the caller never receives unvalidated data.

What the output means: A validated Answer on success, or a ValueError if the model never complies. The key idea to state out loud in an interview: feed the validation error back as part of the retry prompt.

Try this: Explain aloud why this beats string-parsing the reply: the schema is the contract, and the model self-corrects from the error message you return to it.

Retry with exponential backoff + jitter

Retry only transient errors; space attempts out with randomized backoff to avoid a thundering herd.

pythonimport random, time
def retry(fn, tries=5, base=1.0):
    for attempt in range(tries):
        try: return fn()
        except Exception:
            if attempt == tries - 1: raise
            time.sleep(random.uniform(0, base * 2 ** attempt))
▶ How this works

The compact interview form of the retry loop. Same idea as section 6 — retry transient failures with exponentially growing, randomized delays — but short enough to write on a whiteboard.

  1. for attempt in range(tries) tries fn() and returns its result on the first success.
  2. if attempt == tries - 1: raise gives up after the final attempt so you don't loop forever.
  3. time.sleep(random.uniform(0, base * 2 ** attempt)) is the whole trick: 2 ** attempt doubles the window each round (exponential backoff) and random.uniform(0, ...) picks a random point inside it (jitter) so clients don't retry in lockstep.

What the output means: No direct output — it returns fn()'s value or re-raises after the last try, with growing, randomized pauses between attempts.

Try this: Be ready to name the two ingredients and why each matters: exponential (don't hammer a recovering server) and jitter (avoid the thundering herd where everyone retries at once).

Checkpoint expert

  • Model, validate, and coerce data with Pydantic including cross-field validators.
  • Use discriminated unions to parse tool calls into typed objects safely.
  • Get schema-valid LLM output via native structured output or validate-and-retry.
  • Classify errors and apply retry+backoff+jitter, a circuit breaker, timeouts, idempotency, and fallbacks.

🪜 Practice ladder beginner → industry

Six graded exercises, easy to real-world. Try each before opening its solution.

Exercise 1 · Validate at the boundary (no pydantic)Beginner

Context: Untrusted input has to become a known-good shape at the edge of your system, so everything downstream can assume correctness. This is exactly what Pydantic automates — here you do it by hand to see the idea underneath.

Your task: Write parse_user(d) that accepts a dict only when it has a non-empty string name and an int age >= 0, raising ValueError otherwise.

Requirements:

  • Reject a missing or empty name, or one that isn't a str
  • Reject a negative age or one that isn't an int
  • Treat a bool as not a valid age (it's an int subclass)
  • Raise ValueError with a message naming the offending field
  • Return the cleaned dict on success and demo both an accept and a reject

💡 Hint: Check each field with isinstance before using it, and remember True/False pass isinstance(x, int) — guard the bool case explicitly.

Show solution

Validation converts untrusted input into a known-good shape at the edge, so the rest of the code can assume correctness. (Pydantic automates this; here it's explicit.)

def parse_user(d):
    name = d.get("name")
    age = d.get("age")
    if not isinstance(name, str) or not name:
        raise ValueError("name must be a non-empty string")
    if not isinstance(age, int) or isinstance(age, bool) or age < 0:
        raise ValueError("age must be a non-negative int")
    return {"name": name, "age": age}

print(parse_user({"name": "Ada", "age": 36}))
try:
    parse_user({"name": "", "age": -1})
except ValueError as e:
    print("rejected:", e)
Exercise 2 · Coercion with a validatorIntermediate

Context: Real inputs are messy: form fields and query params arrive as strings even when you want numbers. A validator coerces what is safely convertible and rejects the rest — Pydantic's field_validator does exactly this.

Your task: Write coerce_age(v) that accepts an int as-is, converts a numeric string to int, and rejects anything non-numeric.

Requirements:

  • An int passes through unchanged
  • A numeric string like "42" is coerced to the int 42
  • A non-numeric string like "old" raises ValueError
  • A bool is rejected rather than silently treated as 0/1
  • Demonstrate an int input, a string input, and a rejected input

💡 Hint: Branch on type: handle the clean int first, then test a string for digit-ness before calling int() so you reject rather than crash on bad text.

Show solution

Real inputs are messy (form fields arrive as strings). A validator coerces what is safely convertible and rejects what isn't — Pydantic's field_validator does exactly this.

def coerce_age(v):
    if isinstance(v, bool):
        raise ValueError("bool is not a valid age")
    if isinstance(v, int):
        return v
    if isinstance(v, str) and v.strip().lstrip("-").isdigit():
        return int(v)
    raise ValueError(f"cannot coerce {v!r} to int age")

print(coerce_age(30))       # 30
print(coerce_age("42"))     # 42
try:
    coerce_age("old")
except ValueError as e:
    print("rejected:", e)
Exercise 3 · Discriminated union dispatchAdvanced

Context: Many payloads are 'one of several shapes' — different message types down one channel. A discriminated union uses a tag field to pick the right schema, the reliable way to parse this without ambiguity.

Your task: Write parse_shape(d) that reads a type discriminator, validates the fields that shape requires, and returns the computed area.

Requirements:

  • type == "circle" requires radius and returns its area
  • type == "rect" requires w and h and returns their product
  • An unknown or missing type raises ValueError naming it
  • Dispatch on the discriminator — don't guess the shape from which keys exist
  • Demonstrate both valid shapes and one rejected unknown type

💡 Hint: Read the type field first and branch on it; only inside each branch do you reach for that shape's required fields.

Show solution

A discriminated union uses a tag field to pick the correct schema — the reliable way to parse ‘this could be any of N message types’ without ambiguity.

import math

def parse_shape(d):
    t = d.get("type")
    if t == "circle":
        r = float(d["radius"]); return ("circle", math.pi * r * r)
    if t == "rect":
        return ("rect", float(d["w"]) * float(d["h"]))
    raise ValueError(f"unknown shape type: {t!r}")

print(parse_shape({"type": "circle", "radius": 2}))     # ('circle', 12.566...)
print(parse_shape({"type": "rect", "w": 3, "h": 4}))     # ('rect', 12.0)
try:
    parse_shape({"type": "blob"})
except ValueError as e:
    print("rejected:", e)
Exercise 4 · Retryable vs fatal error taxonomyExpert

Context: Not all failures are equal: a timeout or 503 is transient and retrying helps, while a 400 or auth error is deterministic and retrying just wastes money and hammers a broken dependency. Classifying errors is the foundation of every retry policy.

Your task: Define a retryable/fatal error taxonomy, write should_retry(exc), and a retry loop that re-attempts only the retryable failures.

Requirements:

  • Model at least two error classes: one meant to be retried, one meant to be fatal
  • should_retry(exc) returns True only for the retryable class
  • The loop stops immediately on a fatal error — no wasted attempts
  • The loop gives up after a bounded number of retries and re-raises
  • Show a flaky call that succeeds on retry and a fatal call that is never retried

💡 Hint: Distinct exception subclasses make the decision a one-line isinstance check; in the loop, re-raise when either the error is fatal or you're out of attempts.

Show solution

Not all failures are equal: a timeout or 503 is transient (retry helps); a 400/validation error is deterministic (retry just wastes time and money and may hammer a broken dependency).

class Retryable(Exception): pass      # e.g. timeout, 429, 503
class Fatal(Exception): pass          # e.g. 400, auth, bad schema

def should_retry(exc):
    return isinstance(exc, Retryable)

def run(fn, retries=3):
    for attempt in range(retries):
        try:
            return fn()
        except Exception as e:
            if not should_retry(e) or attempt == retries - 1:
                raise
    # unreachable

state = {"n": 0}
def flaky():
    state["n"] += 1
    if state["n"] < 2:
        raise Retryable("503")
    return "ok"

print(run(flaky))                     # ok (retried once)
try:
    run(lambda: (_ for _ in ()).throw(Fatal("400 bad request")))
except Fatal as e:
    print("not retried:", e)
Exercise 5 · Exponential backoff with jitterProfessional

Context: Backoff spaces retries so you don't pound a struggling dependency, and jitter randomizes each delay so a fleet of clients doesn't retry in lockstep and cause a synchronized thundering-herd — the retry-storm that turns a blip into an outage.

Your task: Write a function that computes the sequence of backoff delays with exponential growth, a ceiling, and full jitter (compute the sleeps, don't actually sleep).

Requirements:

  • Each attempt's ceiling grows exponentially (roughly doubling) and is capped
  • Each delay is a random value drawn between 0 and that attempt's ceiling (full jitter)
  • Take a seed so the demo is reproducible while still randomized
  • Show the ceilings grow while the actual delays stay scattered, not aligned
  • Note where time.sleep/await asyncio.sleep would go in production

💡 Hint: Compute min(cap, base * 2**attempt) as the ceiling, then draw the delay from random.uniform(0, ceiling) using a seeded RNG.

Show solution

Backoff spaces retries so you don't hammer a struggling dependency; jitter randomizes the delay so many clients don't retry in lockstep and cause a synchronized thundering-herd (the retry-storm from the outage lesson).

import random

def backoff_delays(retries=5, base=0.1, cap=5.0, seed=0):
    rng = random.Random(seed)
    delays = []
    for attempt in range(retries):
        exp = min(cap, base * (2 ** attempt))   # exponential, capped
        delays.append(round(rng.uniform(0, exp), 3))  # full jitter
    return delays

print(backoff_delays())
# growing ceiling (0.1,0.2,0.4,0.8,1.6...) each randomized in [0, ceiling]
# e.g. [0.084, 0.15, 0.005, 0.63, 1.14]  -- no two clients align

In production you'd time.sleep(d) between attempts (or await asyncio.sleep(d) in async code).

Exercise 6 · Circuit breaker + idempotent fallbackIndustry scenario

Context: When a dependency dies, even correct retries pile on and slow your own recovery. A circuit breaker fails fast while the dependency is down and probes for recovery, protecting both sides — the standard resiliency pattern in production services.

Your task: Build a circuit breaker that opens after N consecutive failures, fails fast while open, half-opens to probe recovery, and falls back to a cached idempotent result.

Requirements:

  • Starts closed; calls pass through and reset the failure count on success
  • Opens after N consecutive failures and then fails fast without calling the dependency
  • After a cooldown it goes half-open and lets a single probe through
  • A successful probe closes it; a failed probe re-opens it
  • While open it returns a cached, idempotent fallback rather than an error
  • Demonstrate all three states (closed, open, half-open) in one run

💡 Hint: Track consecutive failures and an opened_at timestamp; gate every call on a small state check that flips open→half-open once the cooldown elapses.

Show solution

Design: the breaker tracks consecutive failures. Closed = calls pass through. After N failures it opens and fails fast (protecting both you and the dead dependency). After a cooldown it goes half-open and lets one probe through; success closes it, failure re-opens. A cached last-good result gives a graceful fallback while open.

import time

class CircuitBreaker:
    def __init__(self, threshold=3, cooldown=0.5):
        self.threshold, self.cooldown = threshold, cooldown
        self.fails = 0; self.opened_at = None; self.state = "closed"
    def _can_try(self):
        if self.state == "open":
            if time.monotonic() - self.opened_at >= self.cooldown:
                self.state = "half-open"; return True
            return False
        return True
    def call(self, fn, fallback):
        if not self._can_try():
            return fallback()                      # fail fast -> fallback
        try:
            res = fn()
        except Exception:
            self.fails += 1
            if self.fails >= self.threshold:
                self.state = "open"; self.opened_at = time.monotonic()
            return fallback()
        self.fails = 0; self.state = "closed"        # recovered
        return res

cache = {"last": "stale-but-ok"}
def dead():
    raise RuntimeError("dependency down")
def fallback():
    return cache["last"]                             # idempotent, safe

cb = CircuitBreaker(threshold=2, cooldown=0.2)
print(cb.call(dead, fallback), cb.state)   # stale-but-ok closed
print(cb.call(dead, fallback), cb.state)   # stale-but-ok open (fails hit threshold)
print(cb.call(dead, fallback), cb.state)   # stale-but-ok open (fast fail)
time.sleep(0.25)
print(cb.call(lambda: "live!", fallback), cb.state)  # live! closed (half-open probe ok)

Lesson: retries handle transient blips; a circuit breaker handles a dependency that is actually down — it stops the retry storm, fails fast, and an idempotent fallback keeps the product usable while the dependency recovers.

Knowledge check check yourself

✓ Knowledge check

Why does the lesson call a discriminated union "perfect for agents" when validating an LLM's tool call?

Show answer
A tool call could be any of several shapes with different fields; the tag/discriminator field lets Pydantic validate the JSON into exactly the right typed object in one step, rejecting malformed calls before they reach the executor.
✓ Knowledge check

Retry-with-backoff adds random jitter to the exponential delay and retries only errors classified as Retryable. Explain the purpose of each choice.

Show answer
Jitter randomizes each client's wait so a crowd doesn't retry in lockstep and re-crush a recovering server (the thundering herd); classifying first means permanent 4xx errors fail fast instead of wasting retries that can never succeed.
© 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