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.
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.
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
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.
- Each line inside the
classis a field: a name, its type, and optional rules.summary: str = Field(min_length=1, max_length=500)says "a string, 1–500 characters". severity: Literal["low", "medium", "high", "critical"]restricts the value to exactly those four words — anything else is rejected.confidencemust be a number between 0 and 1 (ge=0.0, le=1.0= greater-or-equal / less-or-equal).affected: list[str] = []gives a default (an empty list), so the field is optional.- 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.
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.
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'
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.
- The
@field_validator("service")method runs on theservicevalue andreturn v.strip().lower()normalizes it — trimming spaces and lowercasing — so" Web "becomes"web". Whatever you return replaces the stored value. - The
non_negativevalidatorraises aValueErrorwhenreplicasis below zero. Raising inside a validator is how you reject bad input. @model_validator(mode="after")runs once all fields exist, so it can compare them: here it checksreplicasagainstmax_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.
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
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.
- Each class has an
actionfield 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. Action = Annotated[Union[Scale, Restart, Rollback], Field(discriminator="action")]bundles the three shapes and tells Pydantic: look atactionto decide which one.TypeAdapter(Action)wraps that union so you can validate raw data against it.adapter.validate_python({...})reads"action": "rollback"and validates the dict as aRollback, requiringto_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.
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.
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}")
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.
- 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. parse_with_retryloops up totriestimes. On the first pass it sends the plainprompt; on later passes it appends the previous error so the model can correct itself.schema.model_validate_json(raw)tries to validate the model's raw text. If it works, wereturnimmediately — success.- If it raises
ValidationError, we save the message inerrand loop. After all tries fail, weraise ValueErrorso 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.
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.
| Category | Examples | Action |
|---|---|---|
| Transient | 429 rate limit, 503, timeout, connection reset | Retry with backoff |
| Client/permanent | 400 bad request, 401 auth, 404, validation error | Fail fast — fix the request |
| Overload (server) | 529 overloaded | Retry with longer backoff / fallback model |
| Refusal / safety | content declined | Handle explicitly (fallback / surface to user) |
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")
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.
RetryableandFatalare custom exception classes (the...body just means "empty"). Their type carries the decision.- If
statusis one of the transient codes (429 rate-limit, 500/502/503, 529 overloaded), itraisesRetryable— a signal that backing off and trying again may work. - The check
if 400 <= status < 500catches the other 4xx client errors (bad request, auth, not-found) and raisesFatal— 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").
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
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.
- The
for attempt in range(tries)loop triesfn()and returns its result the moment it succeeds. except Fatal: raisere-throws permanent errors immediately — never waste retries on something that can't succeed.except Retryablehandles the transient case. If this was the last attempt (attempt == tries - 1) it gives up and re-raises; otherwise it waits and loops.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.
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.
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
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.
thresholdis how many failures in a row trip the breaker;cooldownis how long it stays open.opened_atrecords when it tripped (Nonemeans closed/healthy).- In
call(), if the breaker is open and still within the cooldown, itraises immediately — "circuit OPEN — failing fast" — without touching the service. - Once the cooldown passes it clears
opened_atto go half-open: it allows one trial call to see if the service is back. - On any exception it bumps
self.failuresand trips the breaker once the threshold is hit. On success it resetsfailuresto 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.
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.
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))
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.
_doneis a dictionary standing in for a real dedup store (a DB or Redis in production). It remembers which keys already ran.execute_once(key, action)checks the store first: if thekeyis 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.with_fallback(primary, fallback)triesprimary()and, on any exception, quietly runsfallback()instead — e.g. a cheaper model, a cached answer, or an "escalate to a human" path.- 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.
Exercises expert
- Model a
SupportAnswerwith a confidence 0–1, a Literal action, and a validator that forcesaction="escalate"when confidence < 0.5. - Build a discriminated union of 3 tool calls and a function that dispatches each to the right handler.
- Wrap
parse_with_retryaround a mock model that returns bad JSON twice then good JSON; assert it succeeds. - Combine
retry+CircuitBreaker: retries within a call, breaker across calls; test the breaker trips after N failures. - 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.
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")
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.
Answeris a minimal Pydantic model — asummarystring and aconfidencenumber — the target shape you insist the model produce.parse_with_retryloopstriestimes. On retries it appends\nFix: {err}to the prompt so the model sees exactly what it got wrong.Answer.model_validate_json(raw)validates the raw text; success returns the typed object, failure is caught asValidationErrorand stored inerrfor the next pass.- 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 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))
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.
for attempt in range(tries)triesfn()and returns its result on the first success.if attempt == tries - 1: raisegives up after the final attempt so you don't loop forever.time.sleep(random.uniform(0, base * 2 ** attempt))is the whole trick:2 ** attemptdoubles the window each round (exponential backoff) andrandom.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.
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 astr - Reject a negative
ageor one that isn't anint - Treat a
boolas not a valid age (it's an int subclass) - Raise
ValueErrorwith 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)
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
intpasses through unchanged - A numeric string like
"42"is coerced to the int42 - A non-numeric string like
"old"raisesValueError - A
boolis 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)
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"requiresradiusand returns its areatype == "rect"requireswandhand returns their product- An unknown or missing
typeraisesValueErrornaming 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)
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)
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.sleepwould 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 alignIn production you'd time.sleep(d) between attempts (or await asyncio.sleep(d) in async code).
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
Why does the lesson call a discriminated union "perfect for agents" when validating an LLM's tool call?
Show answer
Retry-with-backoff adds random jitter to the exponential delay and retries only errors classified as Retryable. Explain the purpose of each choice.