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
Picture the demo that goes perfectly, then the launch that doesn't. Your agent parses the model's JSON, calls the deploy tool, and everyone claps. Two weeks later — under real traffic, with a real model that occasionally returns "replicas": "three" instead of 3, and a cluster API that throws a 503 every few minutes — the same agent scales a service to a nonsense number and then retries a half-finished deploy twice. Nothing in the code changed. The world got messier. Everything in this part exists to make the second story impossible.
Here's the mental model worth carrying through the whole page: your agent sits behind a boundary, like the airlock on a spacecraft. Two very different dangers press on it from opposite sides. From the inside-out side, the model and the tools produce data you can't trust — a hallucinated field, a malformed argument, a string where you expected a number. From the outside-in side, the network and downstream services fail in ways you can't control — rate limits, timeouts, servers that briefly fall over. A robust boundary has to be strict on the way in (reject bad data before it triggers a real action) and resilient on the way out (survive transient failures instead of turning them into outages).
The common mistake is treating these as the same problem, or skipping one entirely. Beginners tend to trust model output because "it looked right in testing" — then a single bad field crashes a batch or, worse, executes silently as a wrong action. Or they wrap everything in a blind try/except that retries, which quietly hammers a dead server and re-runs state-changing operations. The two disciplines are genuinely different tools for genuinely different failures, and you need both.
So the precise framing: validation keeps bad data out — it's the parser and type-checker at the door that turns untrusted JSON into typed, rule-checked objects (Pydantic, sections 1–4). Resiliency keeps transient failures from becoming outages — the error taxonomy, retries, circuit breakers, timeouts, idempotency, and fallbacks that let the system bend without breaking (sections 5–8). 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, and if a transient 429/503 isn't retried a whole batch fails. Put the two together and you get the thing every production system is really selling: trustworthiness — the confidence that the agent does the right thing, exactly as many times as intended, even when its inputs and its dependencies misbehave.
Read it left to right through the middle box: the agent is squeezed between two different sources of trouble, and each arrow into it is a separate defense.
- The red box on the left is untrusted data — model output and tool arguments that might be malformed, the wrong type, or hallucinated. The arrow into the agent is labelled VALIDATION: this is the "strict on the way in" side (Pydantic, sections 1–4).
- The amber box on the right is the flaky world — rate limits, timeouts, brief outages you don't control. Its arrow is labelled RESILIENCY: the "resilient on the way out" side (error taxonomy, retries, breakers, idempotency, sections 5–8).
- The white box in the middle is your agent, which turns inputs into real actions — so it must be protected from both directions at once. That is the payoff: they are different tools for different failures, and you need both.
In short: validation keeps bad data out; resiliency keeps transient failures from becoming outages. Together they buy trustworthiness.
1 · Pydantic foundations intermediate → advanced
Think about the last time you filled in a paper form that had rules printed on it. "Date of birth (DD/MM/YYYY)." "Amount, whole numbers only." "Tick one." The form isn't just collecting data — it's enforcing a shape, and it rejects nonsense at the point of entry so nobody downstream has to guess what you meant. A Pydantic model is exactly that form, written once in code, and applied automatically to every piece of data that tries to come through your door.
Why this matters so much for LLM work specifically: a model hands you back text that looks like JSON, and your instinct is to json.loads() it and start reading fields. That's the trap. Raw JSON gives you a dictionary with no promises — data["confidence"] might be 0.9, might be "0.9", might be 2.0, might be missing entirely, and you won't find out until three functions later when something divides by it and blows up far from the real cause. Pydantic moves that discovery to the edge: you declare the shape you require, and every value is checked, converted, and rule-verified the instant it arrives. Bad data fails here, with a precise message, instead of leaking inward as a mystery bug.
The common beginner mistake is hand-writing this checking: a wall of if not isinstance(x, str): ... and if conf < 0 or conf > 1: ... scattered through the code. It's verbose, it drifts out of sync with reality, and it usually reports only the first problem it hits. Pydantic replaces all of that with a single declarative class: you say what you want (a string 1–500 chars, a float in 0–1, one of four allowed words) and it figures out how to check it — reporting every violation at once, which is what makes debugging an LLM's output bearable.
Now the precise version. Pydantic parses and validates data against typed models — the de-facto standard for LLM structured output, config, and API boundaries. You subclass BaseModel, annotate each field with its type and optional constraints (via Field(...)), and Pydantic gives you three things for free on every instantiation: validation (rules are enforced), coercion (a "3" becomes 3 where sensible), and clear errors (a structured ValidationError listing each bad field). Define the shape once; get all three everywhere you use it.
BaseModel validates, coerces, and checks every field, then either emits a fully-typed object or a ValidationError naming all the bad fields — bad data dies at the edge, not three functions later.
Follow one payload left to right. It arrives as untrusted JSON, passes through the model in the middle, and leaves as exactly one of two things.
- The red box on the left is the raw dict an LLM hands back — values that might be the wrong type (
"0.9"as a string) or an illegal choice. No promises yet. - The blue middle box is the
BaseModeldoing all three jobs at once: enforcing rules, coercing safe conversions ("3"→3), and checking each field. - The two arrows out are the only possible outcomes: a green typed object (rules guaranteed) when everything passes, or an amber
ValidationErrorthat names every violation at once when it doesn't.
In short: declare the shape once and every value is validated, coerced, and error-checked at the boundary — you get a trustworthy object or a precise complaint, never a silent mystery.
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
Types get you surprisingly far, and then they hit a wall. A type can say "this is an integer" but not "this integer must not exceed the max_replicas field two lines up." It can say "this is a string" but not "trim the whitespace and lowercase it first." Real-world rules are relational and messy in ways a type annotation simply can't express — and that gap is exactly where a validator lives.
The analogy is the difference between a bouncer checking IDs and a bouncer who also knows the house rules. The type check is the ID scan: is this a valid driver's license, yes or no. But the club also has rules the license can't encode — "no more guests than the fire code allows," "members-only after 10pm." Those need someone who can look at the whole situation, not just one card. A field validator is the ID scanner with a little extra polish (it can clean and reshape a single value); a model validator is the manager who sees every field at once and enforces the rules that only make sense in combination.
Here's a subtlety that trips people up, and it's worth slowing down for: Pydantic coerces by default. Hand it "3" where it expects an int and it will happily give you 3 — which is usually a gift (LLMs and web forms love to send numbers as strings) but occasionally a landmine. If a field arriving as a string instead of a number is itself a sign that something upstream is broken, silent coercion hides the bug. That's what strict mode is for: it turns off the helpful conversions so "3" is rejected as "not an int." The mistake to avoid is assuming coercion is always on or always off — it's a deliberate choice you make per model or per field, trading convenience against precision.
The mechanics, precisely: beyond types you often need custom rules and cross-field checks. A @field_validator("x") method receives one field's value, can clean or reject it, and whatever it returns replaces the stored value (raising ValueError is how you reject). A @model_validator(mode="after") runs once every field is populated, so it can compare fields against each other — the cross-field rules a single-field check can't reach. And coercion is the default behavior you opt out of with strict mode when you'd rather catch a type mismatch than paper over it.
"3"→3 by default unless you opt into strict mode.
Read it as two rows, top and bottom — each row is a different scope of checking, and the arrows show what each validator can touch.
- The top-left blue box is a
@field_validator: it sees exactly one field's value. Its two arrows are its only outcomes — the green box (clean/reshape the value, e.g. trim and lowercase, and that return replaces the value) or the red box (raiseValueErrorto reject). - The bottom-left purple box is a
@model_validator(mode="after"): it runs only once every field is populated, so its arrow reaches the wide box holding a cross-field rule (replicas ≤ max_replicas) — the comparison a single-field check simply can't see. - The blue caption line is the coercion subtlety: by default Pydantic converts
"3"→3for you, and strict mode is how you turn that off when a string-where-you-wanted-a-number is itself the bug.
In short: field validators clean or reject one value; model validators enforce rules across fields; coercion is a helpful default you can switch off with strict mode.
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
Every tool-calling agent hits this problem on day one. The model says "I want to do something," and that something could be scale a service, or restart it, or roll it back — three actions with three different sets of required fields. A rollback needs a to_version; a restart doesn't. You get a raw dict and have to answer: which kind of action is this, and does it have the right fields for that kind? Get it wrong and you either crash or, worse, execute a malformed action against real infrastructure.
The naive fix is a staircase of ifs: "if action is 'rollback', check for to_version; elif it's 'scale', check for replicas…" — hand-written dispatch that's tedious, easy to get subtly wrong, and silently accepts a rollback that forgot its version. The parcel-sorting analogy makes the better idea obvious: a sorting facility doesn't open every package to guess where it goes: it reads the label. One field on the outside tells the machine exactly which bin, and the contents are checked against that bin's rules. A discriminated union is that label-driven sorter for your data.
Here's how it works: you designate one field as the discriminator (the tag — action here), and each candidate model pins that field to a single literal value (Literal["rollback"]). Pydantic reads the tag first, jumps straight to the matching model, and validates against only that shape — requiring exactly its fields. This is dramatically better than an untagged union, where Pydantic would try each shape in turn and hand you confusing errors from every failed attempt; the tag turns an ambiguous "figure out what this is" into a single, unambiguous, typed dispatch. The common mistake is forgetting the discriminator and using a bare Union: it often still works, but the error messages become a wall of noise and validation gets slower because every branch is attempted.
Precisely, then: a tool-calling agent receives actions that could be any of several types, each with different fields, and a discriminated union uses a tag field to tell Pydantic which model to validate against — turning that mess into a typed, exhaustive dispatch. The result is safety at the boundary: LLM JSON becomes a correctly-typed action object, or it's rejected with a message naming exactly what's missing, before it ever reaches your executor.
action discriminator first, jumps straight to the one matching model (here Rollback), and validates against only its fields — a rollback missing to_version is rejected with a precise error instead of a wall of noise from every branch.
Follow the solid arrow from left to right: the JSON comes in, its tag is read, and it is routed to exactly one shape.
- The red box on the left is the incoming tool-call JSON — it could be any of several shapes, so its type is unknown until you look.
- The purple box reads the
actionfield — the discriminator. The three boxes on the right are the candidate models; the dashed arrows are the branches not taken and the solid arrow is the chosen one ("rollback"→ the greenRollbackmodel, which requiresto_version). - The red box at the bottom is the payoff for safety: if the fields don't match the tag's shape, you get a
ValidationErrornaming exactly what's missing — before the action ever runs.
In short: one tag turns "figure out what this is" into a single, unambiguous, typed dispatch — matched shape validated, everything else rejected cleanly.
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
The single most fragile line of code in most early agent projects is the one that reads the model's answer. Someone asks the model to "reply with the summary and a confidence score," gets back a paragraph of prose with the number buried in it, and writes a regex to fish it out. It works in the demo. Then the model phrases it differently next Tuesday, the regex misses, and the whole pipeline silently ingests garbage. Parsing prose is building on sand — the model's job is to be fluent and varied, which is exactly the opposite of what a parser needs.
The fix reframes the whole interaction. Instead of asking a question and hoping to extract structure from the reply, you hand the model the shape you require up front — your Pydantic schema — and constrain it to fill that shape in. Think of it as the difference between asking a witness to "tell me what happened" versus handing them a form with labelled boxes. The free-text answer is richer but unparseable; the form is boring and machine-readable. For data you're going to act on programmatically, boring and machine-readable wins every time.
But — and this is the honest part beginners skip — even schema-constrained output isn't guaranteed. Models still occasionally emit JSON that's malformed or violates a constraint (a confidence of 1.4, a missing required field). So the reliable pattern has two moves, not one: bind and validate, then retry-with-feedback. You validate the response against your schema; if it fails, you don't just throw it away and try the identical prompt again — you feed the validation error itself back to the model ("your last output failed: confidence must be ≤ 1.0; return valid JSON"). Models are strikingly good at fixing their own mistakes when shown the specific complaint, and the second attempt usually succeeds. The mistake to avoid is retrying blindly without the error message — that just rolls the same dice again.
Precisely: 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 — it converts the model from an unpredictable text generator into a component that returns typed, validated objects or fails loudly. As the note after the lab stresses, prefer your SDK's native structured-output path (which does the bind-validate-retry loop for you) and hand-roll it only when you must; either way, the iron rule is that you never trust unvalidated model text as data.
ValidationError back into the prompt so the model can self-correct — bounded by a try limit so it fails loudly instead of forever.
Start top-left and follow the arrows; the shape is a loop, so watch for the arrow that curves back.
- The blue box calls the model; its output flows into the white "validate vs schema" box, which is the decision point.
- If the output is valid, the solid arrow leads straight to the green "typed object" — done. If it's invalid, the dashed arrow drops to the amber box, which appends the validation error to the prompt and the curved blue arrow loops back to the model — that feedback is the whole trick.
- The red box is the escape hatch: after
Ntries the loop stops retrying and raises, so a stubborn model fails loudly rather than looping forever.
In short: validate, and on failure retry with the error text fed back — the model usually fixes its own mistake on the next pass, and the try-limit guarantees you never spin forever.
pythonfrom pydantic import BaseModel, ValidationError
# Preferred: the SDK parses INTO your model for you —
# Claude: resp = client.messages.parse(model="claude-opus-4-8",
# messages=[...], output_format=Diagnosis); resp.parsed_output
# OpenAI: resp = client.responses.parse(model="gpt-5.5",
# input="...", text_format=Diagnosis); resp.output_parsed
# either way you get a validated Diagnosis back.
#
# 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: the SDK's native parse (
client.messages.parse(..., output_format=Diagnosis)on Claude,client.responses.parse(..., text_format=Diagnosis)on OpenAI) parses 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, OpenAI's responses.parse() with a text_format — and retry at the tool-call layer. Use that 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
Here's the failure that costs teams real money, and it comes from a good instinct applied without judgment. "Retries make things reliable," someone reasons, so they wrap every call in a retry loop. Then a request goes out with a typo'd API key — a 401 that will never succeed — and the loop dutifully retries it five times with backoff, burning seconds and, if it's a paid API, money, before failing anyway. The retry didn't help; it made a fast, clear failure into a slow, expensive one. Resiliency without classification is just wasted effort dressed up as robustness.
The idea that fixes this is triage, in the exact medical sense. An emergency room doesn't treat everyone identically — it sorts first, because the right response depends entirely on what kind of problem walked in. A cut that needs stitches and a heart attack get completely different handling, and giving one the other's treatment is worse than doing nothing. Errors are the same: before you decide how to respond, you have to decide what kind of error this is. That sorting step — the taxonomy — is the foundation every later resiliency pattern in this part is built on.
The essential split is transient versus permanent. A transient error is a passing condition of the world — a 429 rate limit, a 503 service-unavailable, a 529 overload, a timeout, a dropped connection. The request was fine; the server was just briefly unable, and trying again later has a real chance of working. A permanent (client) error is a problem with the request itself — a 400 bad request, a 401 auth failure, a 404, a validation error. No amount of retrying fixes a malformed request; the only cure is changing what you send. The table below adds two more buckets worth naming explicitly — server overload (retry, but back off harder or fall back to another model) and safety refusals (not an error to retry at all, but a decision to handle).
Precisely, then: resiliency starts with classification — some errors are transient so retrying helps, others are permanent so retrying just wastes time and money, and you decide the response per error type. The practical trick shown in the lab is to translate the raw signal (an HTTP status code) into your own small set of exception types — Retryable and Fatal — right at the boundary. Once the error carries its classification in its type, the retry loop in the next section becomes trivially simple: retry one type, immediately re-raise the other. Get the taxonomy right and everything downstream falls into place; get it wrong and no amount of clever backoff will save you.
Read top to bottom: one failure enters the classify box at the top and is routed to exactly one of four response buckets below it.
- The purple box at the top is the triage step — the single decision that runs at the boundary before any retry logic. Everything below is a possible outcome.
- The two amber buckets (transient, overload) are the ones you retry: a
429/503/timeout gets ordinary backoff, while a529overload backs off harder or falls back to another model. The red bucket (permanent 4xx) is the fail-fast path —400/401/404can never be fixed by retrying. The white bucket (refusal) is neither error nor retry — it's a decision you handle explicitly. - The payoff is the bottom line: classify before you react, because retrying a permanent 4xx just burns time and money on a request that can't succeed.
In short: sort the failure by kind first — transient/overload retry, permanent fails fast, refusal is handled — and the right response falls out of the bucket.
| 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
The most tempting retry code is also the most dangerous. A server returns 503, so you try again immediately. Still failing? Try again immediately. This "tight retry loop" feels responsive, but picture what happens at scale: a service hiccups, ten thousand clients all get the error at the same instant, and all ten thousand slam it again a millisecond later — and again, and again. Your retries become a denial-of-service attack on a server that was just about to recover. This has a name in operations: the thundering herd, and it's one of the classic ways a small blip becomes a full outage.
Two ideas, layered, defuse it. The first is exponential backoff: instead of retrying at a fixed pace, you wait longer each time — 1 second, then 2, then 4, then 8. The intuition is polite persistence: a brief stumble gets a quick second chance, but a server that keeps failing is clearly struggling, so you back off and give it room to breathe rather than crowding the door. The doubling matters because it grows the breathing room fast without you having to tune each step by hand.
The second idea is jitter, and it's the one people forget. Exponential backoff alone doesn't actually break the herd — if all ten thousand clients back off on the same schedule, they just retry in synchronized waves, hammering the server at 1s, then 2s, then 4s, in perfect lockstep. Jitter adds randomness to each client's wait ("sleep a random amount up to the current delay"), so the crowd smears out across time instead of arriving together. The analogy is leaving a packed stadium: if everyone rushes the exit at once, nobody moves; if arrivals are staggered, everyone gets out faster. A little randomness, counterintuitively, makes the whole system quicker to recover.
The third piece is the discipline from the last section: retry only errors classified as retryable. This loop re-raises Fatal immediately (never waste a backoff cycle on a request that can't succeed) and only backs-off-and-retries the Retryable ones. Precisely, then: retry only retryable errors, and space attempts out exponentially (1s, 2s, 4s…) with jitter so many clients don't retry in lockstep and re-crush a recovering server. One real-world refinement flagged in the lab and the note: when a server sends a Retry-After header it's telling you exactly how long to wait — honor it over your computed delay. And know that mature SDKs (both Anthropic's and OpenAI's) already do this for their own calls; you build it yourself for your tools — the database, the internal API, the webhook — that don't come with it.
Compare the two timelines side by side — both show the same clients retrying along a left-to-right time axis, but with different timing.
- On the left (red), the tick marks bunch into three tight waves: every client retries on the same fixed schedule, so they all strike the server at the same instants.
- On the right (green), the ticks are scattered and the gaps between them grow (1s, 2s, 4s…). That growth is exponential backoff; the scatter within each round is jitter — a random wait up to the current delay.
- The two boxes below name the outcome: synchronized retries are the thundering herd that re-crushes a recovering server, while growing + randomized delays give it room to breathe and stagger arrivals.
In short: exponential backoff widens the gaps so you stop hammering, and jitter randomizes them so a fleet of clients never retries in lockstep.
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
Backoff-and-jitter handles a service that stumbles. But what about one that's genuinely, lengthily down? Say a downstream API has been dead for ten minutes. Every request you send waits for its timeout, fails, waits out its backoff, and fails again — and meanwhile your threads are piling up in those waits, your latency is climbing, and the failure of one dependency is dragging your whole service down with it. Retrying was the right tool for a blip; it's the wrong tool for an outage. You need something that recognizes "this thing is dead, stop asking" and gets out of the way.
The pattern borrows its name and its logic straight from household electrical wiring, and the metaphor is worth taking literally. A circuit breaker in your fuse box doesn't keep pushing current into a short — it trips, cutting the connection to protect the house from a fire. You don't rewire anything; you just wait, then flip it back and see if the fault has cleared. A software circuit breaker does the same for a call to a flaky dependency: after enough failures it "trips" and stops the calls entirely, protecting both your system (no more piled-up waits) and the struggling dependency (no more pile-on load while it tries to recover).
The mechanism is a little three-state machine, and it's worth naming the states because they're the whole idea. Closed is normal: calls flow through, and a running count tracks consecutive failures. Hit the failure threshold and the breaker flips to open: for a cooldown period, every call fails instantly ("circuit OPEN — failing fast") without even touching the dead service. When the cooldown expires it goes half-open: it lets exactly one trial call through as a probe. If that probe succeeds, the service is back — reset to closed. If it fails, trip open again for another cooldown. A single success resets the failure count, so a healthy service is never penalized for old, unrelated failures.
Notice how this composes with the previous section rather than replacing it, because this is the distinction people miss: retry works within a single call (try this operation a few times), while the breaker works across calls (has this dependency been failing so much that we should stop trying at all?). They're different scopes of the same goal, and production systems use both together — retries to ride out brief turbulence, a breaker to bail out of a sustained outage. Precisely: 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.
Read it as a cycle of three states, following the arrows clockwise from the green box on the left.
- CLOSED (green) is healthy: calls pass through and a running failure count ticks up. The arrow labelled ≥ N fails is the trip — enough consecutive failures flips it to OPEN.
- OPEN (red) fails every call instantly without touching the dead service, protecting both sides. When the cooldown elapses, the arrow leads to HALF-OPEN (amber), which allows exactly one trial probe.
- The two curved return arrows are the payoff: a successful probe resets all the way to CLOSED (green arrow), while a failed probe trips straight back to OPEN (red dashed arrow) for another cooldown.
In short: a failure count trips closed→open, a cooldown moves open→half-open, and a single probe decides half-open→closed (recovered) or →open (still down).
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
Everything we've built so far — retries, breakers — has a dark side we've been quietly ignoring. Retrying is safe when the operation just reads something. But what if the operation changes the world? You send a "charge the customer $99" request, the network hiccups before the response comes back, so your retry logic dutifully sends it again — and now the customer is charged twice. The request may have succeeded the first time; you just never heard the "yes." Retrying state-changing actions is how good resiliency code causes double-charges, double-deploys, and duplicate emails. This is the problem you must solve before you're allowed to retry anything that mutates state.
The concept that saves you has an intimidating name — idempotency — but a simple meaning: an operation is idempotent if doing it twice has the same effect as doing it once. Pressing a "close elevator door" button is idempotent (the door is closed either way); pressing "vend one soda" is not (you get two sodas). Most reads are naturally idempotent; most writes are not — and the trick is to make the dangerous writes idempotent by attaching a unique idempotency key to each intended action. The server (or your own dedup layer) records "I've already processed key deploy-42" and, on seeing it again, returns the stored result instead of running the operation a second time. The retry becomes safe not because it stops happening, but because the second execution is quietly turned into a no-op.
The partner idea is the graceful fallback, which answers a different question: not "how do I retry safely" but "what do I do when everything I tried has genuinely failed?" The mistake here is letting a single failed call crash the whole request — a dead end for the user. A fallback is the Plan B you reach for instead: if the frontier model is overloaded or refuses, fall back to a cheaper model, a cached answer, or an "escalate to a human" path. The system degrades — the answer might be less good — but it stays useful, which is almost always better than a stack trace. Think of it as the restaurant that's out of the special offering you a solid second choice rather than sending you home hungry.
Precisely: when you retry an action that changes state you risk doing it twice, and an idempotency key makes repeats safe by having the server or dedup layer recognize the key and apply the operation once; when a call ultimately fails, a fallback keeps the system useful. As the closing note ties together, idempotency is a correctness guardrail that complements the safety gate from the capstone — the gate decides whether a state-changing action may run, and the idempotency key guarantees how many times it actually does. Together with everything above, that's the full boundary: strict on the way in, resilient on the way out, and exactly-once where it counts.
Follow one call from the left into the decision box, and notice the two branches out of it.
- The blue box is a call tagged with an idempotency key (e.g.
deploy-42). It flows into the white "key in store?" box — the dedup layer that decides what happens next. - The solid "no (first)" arrow goes to the amber box: the real, state-changing action runs once and its result is saved under the key. The dashed "yes (repeat)" arrow goes to the green box: a later call with the same key returns the cached result without re-running — so a retry can't deploy or charge twice.
- The purple bar along the bottom is the partner idea, fallback: when a call ultimately fails, take Plan B (cheaper model, cached answer, escalate) so the system degrades instead of crashing.
In short: the key guarantees the action happens exactly once no matter how many retries hit it, and the fallback guarantees a final failure stays 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.