Skip to content
abhinavbhatia
Sheet 02 / 05 — Blogs← Archive

Production-ready agents, Part 11.5: Fallbacks and budgets

Models time out, hit rate limits, return broken JSON and quote numbers nobody gave them. This part sorts the ways a turn can fail, builds a fallback ladder that is decided in code before anything breaks, and adds a budget for tokens, cost, model calls and time that is checked while the turn runs, not in next month's report.

It is 9 o’clock on a Monday morning at Acme Consumer Brands. Maria in marketing asks the assistant “What did the Acme Pure stockouts in Western Europe cost us?”, and so do forty of her colleagues, each with their own question, in the same minute. The writer model answers the first few. Then the model endpoint starts returning HTTP 429, the code for “too many requests”. What does Maria see?

Until now, the honest answer was “it depends on luck”. Part 11’s usage ledger and cost per resolved question arrive after the fact, in a report. Nothing in the running turn decides what to do when a call fails, or when one question has already spent more than it should.

This part fills that gap before Part 12 gives the system to real users. It sorts the ways one turn can fail, builds a ladder of deterministic fallbacks decided in code before anything breaks, and adds a budget for tokens, cost, model calls and time that is checked while the turn runs.

At Acme, every model call already retries, and the number check, the kill switch and the usage ledger are in place. The budget plugin, the circuit breaker and deadline propagation are shown as sketches of how they would work at Acme; they are not in the series’ repository.

If you prefer to watch first, here is the heart of this part as a short animated explainer.

Video: how an agent fails safely and stays inside a budget, in three minutes. The narration is a synthetic voice; the script and animation are mine.

Where are we in the architecture?

This part adds fallbacks and budgets around the router’s model calls, so a failing model or a runaway loop still ends in an honest answer. Part 1.25 walks through the whole picture, layer by layer.

ACME ARCHITECTURE · PART 11.5 AGENT RUNTIME (PART 11) each agent is its own engine with its own identity A2A A2A Shopper Acme's customers P11.75 GE for CX customer chat, voice, reps P11.75 Legal edition industry skills, connectors P11.75 User Maria in marketing P1 Gemini Enterprise where people use agents P12 Other front doors own app (AG-UI), Chat, Slack P12.5 Plugins + Model Armor · checks around every step P9 Router agent Workflow: extract, route, write P1 Supply agent Databricks specialist P3 Marketing agent BigQuery specialist P2 Code sandbox runs generated code P3.5 Skills versioned in Agent Registry P4 Sessions + Memory Bank what it remembers about each user P8 AGENT GATEWAY · EGRESS (FORWARD PROXY) · P12 Gemini models the reasoning engine P1 Other models Model Garden P13 Knowledge RAG and search P14 BigQuery marketing data P2 Databricks supply data P3 Market watch managed agent, public web P1.75 Agent Identity one per agent P7 Auth manager acts for the user P7 Secret Manager no secrets in code P11 Human approval for the one write P9 Cloud Build CI on every change P11 Eval gate blocks a worse agent P10 Cloud Trace one per question P11 Cloud Logging redacted events P11 Agent Analytics BigQuery plugin P9 IDENTITY AND SAFETY SHIPPING MODELS, KNOWLEDGE, DATA OBSERVABILITY deploy this part earlier parts later parts Pn = the part that adds it

where Part 11.5 sits in the Acme architecture: highlighted boxes are the subject of this part, solid boxes come from earlier parts, faded boxes arrive later

What do I need to know first?

These are the terms this part uses, each with a plain explanation:

  • Failure mode: one specific way a step can go wrong, such as a timeout or a rate limit.
  • Transient error: a failure that may go away if you try the same thing again a little later, such as an overloaded server. A permanent error will fail the same way every time, such as a request the model’s safety filter blocks.
  • Retry: sending the same request again after a failure. A bounded retry has a maximum number of attempts.
  • Exponential backoff: waiting longer before each retry, for example 1, 2, then 4 seconds, so a struggling service gets time to recover.
  • Jitter: a random amount added to (or taken from) each wait, so that many clients do not all retry at the same instant.
  • Idempotent: a request that has the same effect however many times it runs. Reading a table is idempotent; creating an invoice is not.
  • Circuit breaker: code that stops calling a dependency after repeated failures, for a while, so the system fails fast instead of waiting on something that is down.
  • Budget: the most a single request may spend, measured in tokens, money, model calls and time.
  • Deadline: the point in time by which the whole turn must finish. Deadline propagation means passing the time that is left down to every step the turn calls.

What fails, and how?

Failures are not all the same failure, so they do not all get the same fix. The split that matters is transient (try again a little later and it may work) versus permanent (it will fail the same way every time, however often you try).

Start with something simpler than an agent: phoning a bakery to order a birthday cake.

1. The line is engaged.            Ring back in a few minutes: it will probably work.  (transient)
2. Nobody picks up for 2 minutes.  Ring back later, or try another branch.             (transient)
3. "This number does not exist."   Ringing back changes nothing. Find another way.     (permanent)
4. They read the order back        Ringing back does not help either: correct it once.  (bad output)
   as "one steak".

Where people get tripped up is treating all four the same, usually by ringing back until something works. The engaged line and the silent phone deserve another try; the dead number and the wrong order need a different response. In agent terms, the engaged line is a 429 (rate limit), the silent phone is a timeout, the dead number is a permanent error such as a safety block, and the misheard order is malformed output.

How does this apply to Acme? The same split holds, with more links in the chain.

A turn in our system is a chain: the router’s extract step, one or two specialists over A2A, their tools, and the writer. Any link can fail. These are the eight failure modes I plan for:

EIGHT WAYS ONE TURN CAN FAIL, AND THE KIND OF RESPONSE EACH ONE NEEDS Timeout 408, 504 or no reply before the deadline At Acme: writer call still running at 60 s transient: retry Rate limit HTTP 429 RESOURCE_EXHAUSTED At Acme: Monday 9am: everyone asks at once transient: back off Server error HTTP 500 or 503 UNAVAILABLE At Acme: model endpoint is briefly overloaded transient: retry Bad output MAX_TOKENS, broken JSON, MALFORMED_FUNCTION_CALL At Acme: extract step returns half a JSON object content: repair once Safety block finish_reason SAFETY or a prompt blockReason At Acme: a question trips a safety filter permanent: decline Tool error {"status": "error"} or a refused query At Acme: BigQuery refuses a scan over 2 GB depends: fix or stop Ungrounded answer a figure that is not in the findings At Acme: writer quotes 52,000; no tool returned it content: replace Remote agent fails A2A task failed, or no reply in 300 s At Acme: supply agent is mid-deploy depends: partial answer Retrying the same call later may work. Retrying the same call will not help.

eight ways one turn can fail: timeouts, rate limits and server errors are transient and may succeed if retried later; bad output, safety blocks, tool errors, ungrounded answers and remote agent failures need a different response, such as one repair, a decline, a replacement or a partial answer

  1. Timeout. The call does not finish in time. The Gemini API errors reference lists 504 (deadline_exceeded) for this, and a client may give up first with 408 or a dropped connection. At Acme, the writer reasons for a long time on a hard question.
  2. Rate limit. The project has used more than its quota. The Gemini API returns 429 RESOURCE_EXHAUSTED; its rate limits page says limits apply per project, to requests per minute, tokens per minute and requests per day, and exceeding any one triggers the error. The Agent Platform error code 429 page says the same. Agent Runtime has its own quotas too, such as 90 queries per minute per project and region by default. This is Maria’s Monday morning.
  3. Server error. 500 (internal error) or 503 UNAVAILABLE (temporarily overloaded), usually short.
  4. Malformed or truncated output. The reply is not usable. Its finish reason (why the model stopped) can be MAX_TOKENS (cut off at the output limit) or MALFORMED_FUNCTION_CALL (an invalid tool call), both listed in the Gemini API generate content reference. At Acme, the extract step could return half a JSON object.
  5. Safety block. The finish reason is SAFETY, or the prompt is blocked with a blockReason. The troubleshooting guide says to review the prompt against the safety settings. The same prompt will be blocked again.
  6. Tool error. A tool returns {"status": "error", ...} (Part 1.5’s errors returned as data), or a guard refuses the request, such as BigQuery refusing a query that would bill more than 2 GB (Part 3).
  7. Ungrounded answer. The text reads well but quotes a figure no tool returned. Part 5’s number check exists for exactly this.
  8. Remote agent failure. A specialist is down, slow or returns a failed task. Our router waits up to 300 seconds for a specialist (Part 6), so one slow specialist can hold the whole turn.

The tags are the useful part. Timeouts, rate limits and server errors are transient, so a later retry may succeed. Google’s retry strategy for Agent Platform lists them as the retryable cases: HTTP 408, 429 and 5xx, plus socket timeouts and dropped connections. It also says not to retry other client errors, such as 400 (a bad request) or 401 (not authorised), because they will fail again. The other five failure modes need a different response: one repair, a replacement, a decline or a partial answer.

What is a deterministic fallback?

A deterministic fallback is a response to a failure that you choose in advance and write in code. When failure mode X happens at step Y, the system does Z, every time.

A simple example first. A shop’s till crashes at the busiest hour. A well-run shop does not ask the cashier to improvise. There is a card by the till, written months ago: if the till is down, use the printed price list and the receipt pad; if the card reader is down too, take cash only and put a sign on the door. Every cashier does the same thing on every shift, and the manager can test it in a drill. The crashed till is the failure mode (X), checkout is the step (Y), and the card by the till is the fallback (Z): chosen in advance, written down, the same every time.

How does this apply to Acme? The card by the till becomes a code path, and the cashier who improvises becomes the model that is asked to try again.

The tempting alternative is to ask the model again and hope. If the writer quotes a number that is not in the findings, why not ask it to “please only use the numbers in the findings”? Because that is the instruction it already ignored. A second attempt costs another call, it may fail the same way, and you cannot test it, because the result changes from run to run. Part 5 made the same choice for the number check: it replaces the answer and does not ask for a new one.

So a good fallback has three properties:

  • It is chosen per failure mode. A 429 and a safety block need different responses, so they get different code paths.
  • It is bounded. It has a fixed maximum number of attempts, and it always ends.
  • It is honest. If the system cannot give the full answer, it says so and shows what it does know. It never fills the gap with a guess.

My view: a good fallback is the most useful thing the system can still say truthfully.

What is the fallback ladder?

I arrange the fallbacks as a ladder. Each rung down is cheaper, plainer and more certain than the one above it. A failure starts at the first rung that applies to it and moves down only if that rung fails or does not apply.

It is the same ladder you climb down on a bad commute:

1. The 08:10 train is cancelled.       Wait for the 08:25 on the same line.     (retry)
2. That one is cancelled too.          Take the bus instead.                    (a different model)
3. The bus driver misses the stop.     Point it out once, then carry on.        (one repair)
4. No buses either.                    Walk, with the map you already have.     (tool results only)
5. Too far to walk.                    Ring the office: "I can't get in today."  (honest decline)
6. The line is closed all week.        Stop checking every hour; look tomorrow. (circuit breaker)

Each step down is slower or plainer, but you always end with a true answer to “where are you?”. You never tell the office you are at your desk.

How does this apply to Acme? Each rung has an exact match in our build. The guide below says where each of the eight failure modes above starts, cheapest response first:

Failure mode                        First rung that applies                 If that fails too
1-3 Timeout, rate limit, 5xx        Rung 1: bounded retry                   Rung 2, then 4 or 5
4   Malformed or truncated output   Rung 3: validate, one repair            Rung 4 or 5
5   Safety block                    Rung 5: decline, never retry            nothing further
6   Tool error, bad arguments       Reflect and Retry for tools             Rung 4 or 5
6   Tool error, a guard refused     Rung 4 or 5, never coach around it      nothing further
7   Ungrounded answer               Rung 4: findings only                   Rung 5
8   Remote agent failure            Partial answer from what exists         Rung 5
Any mode, failing again and again   Rung 6: circuit breaker                 kill switch (a person)
THE FALLBACK LADDER: EACH RUNG IS CHEAPER AND PLAINER THAN THE ONE ABOVE Normal path: the model call for this step WHAT MARIA SEES 1 Bounded retry transient errors only, with backoff and jitter the normal answer, a little later 2 Fallback model same step, same prompt, another model the normal answer, another model 3 Validate, repair once check the schema, one fix, then move on a valid structure after one fix 4 Answer from tool results no model: the findings, formatted by code the raw findings, no prose 5 Templated honest decline a fixed message for this failure type "I can't answer that right now" 6 Circuit breaker, kill switch stop calling a dependency that keeps failing a paused message, no model call Orange arrows: "this rung failed or does not apply". Every rung is code decided in advance; none of them asks the model to try harder.

the fallback ladder: when the normal model call fails, the turn steps down through bounded retry, a fallback model, schema validation with one repair, an answer built from tool results only, a templated honest decline, and finally a circuit breaker or kill switch; each rung is decided in code and shows the user something plainer but still true

The animation follows one bad turn: the writer hits rate limits on every retry, so the turn drops to the fallback model. That answer quotes a figure that is not in the findings, so the turn skips rung 3 (which handles broken structure) and Maria gets the findings from rung 4. Each rung below gets the same treatment: what it is, when to use it, Acme’s case and how to build it in ADK.

Rung 1: a bounded retry with backoff and jitter

What it is. Send the same request again, after a wait that grows with each attempt and has a random part, up to a fixed number of attempts.

When to use it. Only for transient errors (408, 429, 5xx and timeouts), and only for idempotent calls. A Gemini call changes nothing on the server, and the Agent Platform retry page says generateContent is generally safe to retry for transient errors. A tool that writes data is a different matter: retrying it can create a duplicate. Google’s Cloud Storage retry strategy makes the same point for its own API: before retrying, consider whether the request is idempotent.

A simple example first. You ring a takeaway twice because the first call dropped:

Asking (idempotent):  "What time do you close?"   Ask twice, hear "10 pm" twice. No harm done.
Ordering (not):       "One large margherita."     The first call got through before it dropped.
                                                  Order again and two pizzas arrive.

The question is safe to repeat; the order is not, unless you first ask “did my order go through?”. The common mistake is to think a retry is free. It costs time the user sits through, and a blind retry of a write can do the work twice.

How does this apply to Acme? Here is the difference with Acme’s own requests. Acme’s one write is the S&OP replan from Part 10’s replan_no_confirm mutant:

Read (idempotent): Maria asks "What did the Acme Pure stockouts in Western Europe cost us?"
  1. The supply agent queries service_cost. The call times out.
  2. Retry: the same query runs again and returns the same rows. Nothing changed, so no harm done.

Write (not idempotent): Sam asks "Start an S&OP replan for Acme Pure"
  1. The replan request reaches the planning system and the replan starts.
  2. The reply is lost and the call times out. From the agent's side it looks like a failure.
  3. A blind retry sends it again: now two replans are running.

So the read gets rung 1, and the write never does. The right response to a timed-out write is to check whether it already happened, or to tell Sam the outcome is unknown, before anything sends it again.

The everyday version of jitter: a shop shuts for ten minutes and tells its whole queue to come back at 10:00 sharp. At 10:00 everyone arrives at once and the door jams again. If each person comes back at a random minute between 10:00 and 10:10, the shop copes. That random spread is the jitter.

Why the jitter? Picture three copies of the router hitting a 429 at the same moment:

THREE ROUTER COPIES HIT A 429 AT THE SAME MOMENT. WHEN DO THEY RETRY? 0s 1s 2s 3s 4s 5s 6s 7s 8s 9s 10s no jitter everyone waits 1, 2, 4 s three retries land together: the overload comes back full jitter random wait up to 1, 2, 4 s retries spread out: the endpoint can recover first call fails (429) retry, at most 3, then the next rung

exponential backoff with and without jitter: three router copies hit a 429 at the same moment; without jitter they all retry after 1, 3 and 7 seconds and overload the endpoint again together; with full jitter each waits a random time up to the backoff limit, so the retries spread out

Without jitter, all three wait exactly the same time and retry together, so the overload returns at 1, 3 and 7 seconds. The Google SRE book’s chapter on addressing cascading failures puts it plainly: “Always use randomized exponential backoff when scheduling retries.” The same chapter warns about retries at several layers: if three layers each try 4 times, one user action can turn into 64 attempts on the struggling service. Its companion chapter on handling overload describes two limits Google uses: at most three attempts per request, and a per-client retry budget that allows retries only while they stay below 10 per cent of requests.

At Acme. Every model in our build already retries. This line is from the router’s writer.py:

writer = Agent(
    name="writer",
    model=Gemini(model=WRITER_MODEL, retry_options=types.HttpRetryOptions(attempts=3)),
    ...
)

(From the series’ project; the extract step and both specialists use the same setting.)

In ADK. The ADK Gemini page shows two places to put retry_options: on the Gemini model object, as above, or in the agent’s generate_content_config. HttpRetryOptions comes from the Google Gen AI SDK and has six settings: attempts (including the first try), initial_delay, max_delay, exp_base (the multiplier), jitter and http_status_codes. The Agent Platform retry page lists the defaults as 5 attempts, a 1 second initial delay, a base of 2, a 60 second maximum and a jitter factor of 1. Spelled out for a chat agent, where the user is waiting, the settings look like this:

# Illustrative: tighter limits for a person who is waiting for an answer.
retry = types.HttpRetryOptions(
    attempts=3,                 # the first try plus two retries
    initial_delay=1.0,          # seconds
    max_delay=8.0,              # never wait longer than this between tries
    http_status_codes=[408, 429, 500, 502, 503, 504],
)
model = Gemini(model=WRITER_MODEL, retry_options=retry)

The Agent Platform page gives the reason for keeping the numbers small: for real-time use such as chat, “Fail fast. Limit the number of retry attempts so users are not left waiting indefinitely for a response.” One honest gap: I have not found documentation that says whether the SDK’s request timeout applies to each attempt or to all of them, so I plan for the worst case (attempts × timeout, plus the waits) and keep that inside the turn’s deadline.

For tools, ADK ships a prebuilt Reflect and Retry plugin, ReflectAndRetryToolPlugin, which gives the model guidance after a failed tool call and lets it try again, up to max_retries (3 by default). That suits bad arguments. It does not suit a refused query: the model must never be coached to work around a guard.

Rung 2: a fallback model for the same step

What it is. If the primary model still fails after its retries, send the same request to a different model.

When to use it. When the failure belongs to one model or one capacity pool: repeated 429s, a 503 that does not clear, or a model that is being retired. Do not use it for a safety block, because a different model is no reason for the prompt to become acceptable.

A simple example: your usual barista is off sick, so a colleague makes the same order from the same recipe. The coffee may be a little plainer, but it is still your order, and the person at the counter still checks it before it goes out. The order is the same request, the colleague is the fallback model, and the check at the counter is the number check that runs either way.

How does this apply to Acme?

At Acme. The writer runs on Gemini 3.1 Pro, which is in Preview. Its fallback is Gemini 3.5 Flash, which is cheaper and already handles the extract step. The answer is a little plainer, but it is grounded in the same findings and goes through the same number check.

In ADK. In Python, the hook is on_model_error_callback. The ADK callbacks guide says it runs when the model call raises an exception, and that returning an LlmResponse suppresses the exception and uses that response instead. The plugins page adds one detail that matters here: after the replacement response, after_model_callback still runs, so the writer’s number check still checks the fallback model’s answer.

# Illustrative: one fallback call per turn, then let the error go to the next rung.
FALLBACK = Gemini(model="gemini-3.5-flash")

async def writer_on_error(callback_context, llm_request, error):
    if callback_context.state.get("temp:writer_fallback_used"):
        return None                              # already tried: do not loop
    callback_context.state["temp:writer_fallback_used"] = True
    llm_request.model = FALLBACK.model
    async for response in FALLBACK.generate_content_async(llm_request):
        return response                          # first full response

The temp: prefix keeps the flag for this turn only. ADK’s state documentation says temp: keys are discarded when the invocation completes. ADK TypeScript has a built-in for this pattern, RoutedLlm, described on the model routing page. As of October 2026 it is experimental and TypeScript only, so in Python we write the callback ourselves.

Rung 3: validate the output, then one repair

What it is. Check the reply against a schema (a description of the exact fields and types you expect). If it fails, ask for one corrected version, and check again.

When to use it. When the output feeds code, such as JSON that drives the next step, and the failure is about structure, not facts.

At Acme. The router’s extract step turns Maria’s question into a short structure that the routing function reads. If the JSON is truncated, routing cannot run.

In ADK. An LlmAgent can take an output_schema, such as a Pydantic model. The LLM agents page notes that if the response fails validation, ADK logs the error and stores the raw string under output_key instead of the parsed object. So the next step must check what it received. The repair is plain Python:

# Illustrative: validate, repair once, then hand over to the next rung.
from pydantic import BaseModel, ValidationError

class Extracted(BaseModel):
    question: str
    metrics: list[str]

async def parse_or_repair(raw: str, repair) -> Extracted | None:
    for attempt in (raw, None):
        try:
            return Extracted.model_validate_json(attempt or await repair(raw))
        except ValidationError:
            continue
    return None                                  # rung 4 or 5 decides what Maria sees

Here is parse_or_repair on one bad reply from the extract step (the reply text is illustrative):

1. raw = '{"question": "Acme Pure stockout cost, Western Europe", "metrics": ["service_pen'
   finish_reason MAX_TOKENS: the JSON stops mid-string, so model_validate_json raises ValidationError.
2. repair(raw) asks once for the same structure, and returns
   '{"question": "Acme Pure stockout cost, Western Europe", "metrics": ["service_penalty_eur"]}'
3. It validates, so the routing function gets a proper Extracted object and the turn carries on.
4. Had the repair also come back broken, the loop ends and the function returns None.
   No third attempt: rung 4 or rung 5 takes over.

The model wrote both replies; code decided that the first was broken, that one repair was allowed and that the turn moves on after it, whatever the second reply looks like.

The limit is one repair. A second repair is the start of “ask again and hope”. For the specific case of a broken tool call, ADK lists a prebuilt Reflect and Retry Model plugin that asks the model to try again when a response ends with an error such as a malformed function call.

Rung 4: an answer built from tool results only

What it is. Skip the model entirely and format the facts the tools returned, in code.

When to use it. When the tools succeeded but the model’s words cannot be trusted or cannot be had: an ungrounded answer, or a writer that failed on every model.

At Acme. This is Part 5’s number check. If a figure in the writer’s answer is not in the findings, the whole answer is replaced with “I couldn’t verify every figure”, followed by the raw findings. It costs no model call, and it cannot invent a number.

# Illustrative: the same idea as the number check's replacement text.
def findings_only(findings: list[dict]) -> str:
    lines = ["I couldn't verify every figure, so here are the findings as the tools returned them:"]
    for f in findings:
        lines.append(f"- {f['metric']}: {f['value']} ({f['source']})")
    return "\n".join(lines)

In ADK. Returning an LlmResponse from after_model_callback replaces the model’s output, which is how Part 5 does it. From on_model_error_callback, the same text becomes the response when every model has failed.

Rung 5: a templated honest decline

What it is. A fixed message for each failure type, written and reviewed in advance.

When to use it. When there are no trustworthy facts to show: a safety block, a specialist that is down with nothing else to report, or a budget that ran out before any tool returned.

At Acme. Part 9’s request policy already declines some questions politely and deterministically. The fallback templates follow the same style. Each one says what happened, in plain words, and what the user can do next:

# Illustrative: one reviewed message per failure type.
DECLINES = {
    "safety":      "I can't help with that request.",
    "unavailable": "The supply data service isn't responding right now. Please try again in a few minutes.",
    "budget":      "This question needs more work than I can do in one go. Could you narrow it down?",
}

In ADK. The same LlmResponse return from a model callback or plugin, or a Content returned from before_run_callback to end the run before any model is called, as Part 9 does for the rate limit.

Rung 6: the circuit breaker and the kill switch

What it is. A circuit breaker, as Martin Fowler describes it in CircuitBreaker, wraps calls to a dependency and counts failures. While things work, the breaker is closed and calls go through. After too many failures it opens, and calls fail straight away without waiting. After a cool-off period it lets a trial call through (half-open), and closes again if that succeeds. The kill switch from Part 9 is the manual version: one setting that pauses the whole assistant.

The software pattern borrows the idea of the breaker in a fuse box. When a faulty heater keeps overloading a circuit, the breaker trips and cuts that circuit, so the wiring does not overheat while someone keeps switching the heater on. The difference, as Fowler notes, is the reset: in a building a person resets the breaker by hand, while a software breaker tries one call after a cool-off and resets itself if the call works. The heater is the failing dependency, the tripped switch is open, the trial call after the cool-off is half-open, and the main switch that cuts the whole house is the kill switch.

How does this apply to Acme? The heater is the writer model.

When to use it. When a dependency is clearly down, and every new turn would otherwise wait for its timeout and retries before failing anyway.

At Acme. If the writer model fails five times in a minute, new turns skip it for two minutes and go straight to rung 2 or rung 4. If the whole system must stop, an operator turns on ACB_KILL_SWITCH.

# Illustrative: a minimal breaker. Like Part 9's rate limit, it lives in one instance's memory.
class CircuitBreaker:
    def __init__(self, threshold=5, window_s=60, cool_off_s=120):
        self.threshold, self.window_s, self.cool_off_s = threshold, window_s, cool_off_s
        self.failures, self.open_until = [], 0.0

    def allow(self) -> bool:
        return time.time() >= self.open_until      # closed, or cool-off over: let one through

    def record(self, ok: bool) -> None:
        now = time.time()
        self.failures = [] if ok else [t for t in self.failures if now - t < self.window_s] + [now]
        if len(self.failures) >= self.threshold:
            self.open_until = now + self.cool_off_s

At Acme, this is how the breaker would work around the writer with the defaults above (threshold 5, window 60 s, cool-off 120 s), on one illustrative Monday morning:

1. 09:00:05 to 09:00:40  Five writer calls fail, each one calls record(False).
                         The fifth failure makes 5 in the window: open_until = 09:02:40. OPEN.
2. 09:01:10  Maria's turn. allow() is False, so the writer on Pro is not called at all.
             The turn goes straight to rung 2 (Flash) and she still gets an answer, with no wait
             on Pro's timeout and retries.
3. 09:02:41  Next turn. allow() is True again: HALF-OPEN, one trial call goes through to Pro.
4. 09:02:47  The trial call succeeds, record(True) empties the failure list. CLOSED.
5. If the trial had failed, the old failures are long outside the 60 s window, so this minimal
   breaker would count only 1 and stay closed until four more fail. A fuller breaker reopens on a
   failed trial call; that is one more line of state.
Kill switch: an operator sets ACB_KILL_SWITCH=on. Every user gets the paused message, and no model
is called, until it is switched off.

The breaker’s decisions are all code: it counts, it opens and it lets one trial call through. The kill switch is the one place a person decides.

In ADK. Call allow() in a plugin’s before_model_callback and pick the next rung when it says no. Call record(False) in on_model_error_callback and record(True) in after_model_callback. ADK has no built-in circuit breaker, so this is generic Python. The kill switch already exists in our build as KillSwitchPlugin, the first plugin in the list.

What is a budget during execution?

A budget is the most one request may spend. In simple terms: before the turn starts, it gets an allowance, and every step draws from it.

Picture a teenager going out for the evening with 20 pounds, a limit of three taxi rides and a promise to be home by 11 pm. They check as the night goes on: down to 4 pounds, so walk instead of a taxi; 10:30, so leave now. The bank statement at the end of the month shows what the evening cost, but it arrives far too late to stop a fourth taxi. The 20 pounds is the cost limit, the three rides are the model call limit, 11 pm is the deadline, and checking before each spend is what makes it a budget rather than a report.

Where teams go wrong is treating a monthly cost dashboard as a budget. Part 11’s cost report is the bank statement: it tells you a turn was expensive, but it cannot stop one.

How does this apply to Acme?

More formally, an execution budget is a per-request allowance of tokens, cost, model calls and wall-clock time, checked while the turn runs, before each expensive step, so the turn can change course or stop. It has four parts:

  • Tokens: input, output and thinking tokens across every model call in the turn.
  • Cost: those tokens priced with Part 11’s pricing.json, in US dollars.
  • Model calls: the number of model calls. This catches loops, even when each call is cheap.
  • Wall-clock time: real time since the turn started, against a deadline.

Part 11’s ledger and cost report measure the same things afterwards, across many questions. A budget acts during one question: it stops tonight’s runaway question at its sixth model call.

A BUDGET IS CHECKED BEFORE EVERY MODEL CALL, WHILE THE TURN RUNS start budget set extract Flash, 1 call check marketing Flash, 2 calls check writer Pro, 1 call check answer to Maria cost (USD) cap 0.08 after extract 0.0086 after marketing 0.0275 after writer 0.0537 model calls cap 6 after extract 1 after marketing 3 after writer 4 tokens cap 40,000 after extract 3,620 after marketing 24,220 after writer 30,570 wall clock (s) deadline 60 after extract 4 after marketing 22 after writer 38 extract marketing writer Costs and tokens: the illustrative counts from Part 11. Times are made up to show the shape.

a budget meter across one Acme turn: the router sets a budget of 0.08 dollars, 6 model calls, 40,000 tokens and 60 seconds, checks it before the extract, marketing and writer calls, and ends at 0.0537 dollars, 4 calls, 30,570 tokens and 38 seconds, inside every limit

With Part 11’s illustrative token counts, a normal marketing question uses 0.0537 dollars, 4 model calls and 30,570 tokens, well inside a budget of 0.08 dollars, 6 calls and 40,000 tokens. The budget is for the abnormal turn: a specialist looping on a failing tool, a retry storm, or a huge writer prompt. The times are made up to show the shape.

How do you choose the numbers? My view: start from the ledger. Take the 95th percentile of cost and calls for resolved questions, add some headroom, and review it monthly.

For example, with illustrative ledger numbers:

1. Take last month's resolved questions from the usage ledger: say 200 of them.
2. 95th percentile cost per question: 0.061 USD. 95th percentile model calls: 5.
3. Add about 30% headroom: 0.061 x 1.3 = 0.079, round to 0.08 USD; 5 x 1.3 = 6.5, round down to 6.
4. Budget: 0.08 USD and 6 calls, the defaults in the plugin below. The normal turn above
   (0.0537 USD, 4 calls) fits; a turn that needs a seventh call is already unusual.

How do I enforce budgets in ADK?

There are six pieces, from the simplest to the most involved.

A hard cap on model calls: RunConfig.max_llm_calls

RunConfig has max_llm_calls, which “caps the total number of LLM calls per run (default: 500)”. Part 1.5 already recommended setting it, because 500 calls is a lot of tokens.

from google.adk.agents.run_config import RunConfig

run_config = RunConfig(max_llm_calls=8)
async for event in runner.run_async(user_id=user, session_id=sid,
                                    new_message=msg, run_config=run_config):
    ...

Two limits apply. First, when the cap is reached, ADK Python raises LlmCallsLimitExceededError (defined in the ADK source), so the run ends with an error, not an answer. Plugins can observe this through on_run_error_callback, but the plugins page says its return value is ignored. Second, RunConfig is chosen by whoever calls the runner. On Agent Runtime, the ADK app template takes run_config as a parameter of each query, so the caller decides. When Gemini Enterprise is the caller, you do not control it. So treat max_llm_calls as a safety net, and enforce your real limit in a plugin that can answer politely.

A budget kept in session state

The budget for one turn lives in temp: state: temp:deadline, temp:usd, temp:calls and temp:tokens. temp: is the right scope because the budget belongs to one invocation and should vanish with it. The state docs also say that sub-agents called by a parent share the same invocation, and so the same temp: state, which means the router’s extract step and writer draw from one budget.

A plugin that checks before each model call and can stop the call

The plugin is a checkpoint in code before every model call, and it can let the call through, change it or stop it. Think of a parent at a shop checkout with the teenager’s spending card. For each item they can nod it through, swap it for the cheaper own brand, or say “that’s enough for today” and close the basket. Nodding through is observe, the cheaper brand is amend, and closing the basket is intervene. The teenager (the model) never decides when the spending stops.

How does this apply to Acme? This is the heart of it. A plugin’s before_model_callback runs before every model call in every agent of the app. The plugins page describes three ways a hook can act: observe (return nothing), intervene (return a value, which skips the model call and uses that value instead) and amend (change the request and let it continue). A budget plugin uses all three:

A BUDGET PLUGIN: CHECK BEFORE EACH MODEL CALL, RECORD AFTER IT model call due any agent, any step before_model_callback read temp:budget and temp:deadline from state plenty left: return None the planned model runs running low: amend request set a cheaper model on it used up: return LlmResponse honest partial answer, no call compares cost, calls, tokens and time left after_model_callback add usage_metadata to it after the model replies next call

a budget plugin: before each model call, before_model_callback reads the budget and deadline from state; with plenty left it returns None and the planned model runs, when running low it sets a cheaper model on the request, and when the budget is used up it returns an LlmResponse with an honest partial answer so no model is called; after_model_callback adds the call's usage to the budget

# Illustrative: a per-turn budget. Plugin hooks are the documented ADK ones.
class BudgetPlugin(BasePlugin):
    def __init__(self, usd=0.08, calls=6, seconds=60, cheap="gemini-3.1-flash-lite"):
        super().__init__(name="budget")
        self.usd, self.calls, self.seconds, self.cheap = usd, calls, seconds, cheap

    async def before_model_callback(self, *, callback_context, llm_request):
        st = callback_context.state
        st.setdefault("temp:deadline", time.time() + self.seconds)
        spent, used = st.get("temp:usd", 0.0), st.get("temp:calls", 0)
        if used >= self.calls or spent >= self.usd or time.time() >= st["temp:deadline"]:
            return LlmResponse(content=types.Content(role="model",
                                                     parts=[types.Part(text=partial_answer(st))]))
        if spent >= 0.75 * self.usd:
            llm_request.model = self.cheap            # amend: a cheaper model for this call
        return None

    async def after_model_callback(self, *, callback_context, llm_response):
        st, u = callback_context.state, llm_response.usage_metadata
        st["temp:calls"] = st.get("temp:calls", 0) + 1
        if u:
            st["temp:usd"] = st.get("temp:usd", 0.0) + usage_cost(u, llm_response.model_version)
        return None

usage_cost is Part 11’s call_cost, fed from the same usage_metadata fields the usage ledger records: prompt_token_count, cached_content_token_count, candidates_token_count and thoughts_token_count. partial_answer is rung 4’s formatting applied to whatever findings are already in state, or rung 5’s budget template if there are none.

At Acme, this is how the plugin would handle a runaway turn: the supply agent keeps calling a tool that keeps failing, and its own BudgetPlugin has the defaults of 0.08 USD and 6 calls. The costs are illustrative; 0.06 USD is the 75% line.

1. Calls 1 to 4  planned model, each check returns None. After call 4: temp:usd 0.062, temp:calls 4.
2. Call 5        check: 0.062 >= 0.06, so AMEND. llm_request.model = "gemini-3.1-flash-lite".
                 The call costs about 0.0033. After it: temp:usd 0.0653, temp:calls 5.
3. Call 6        check: still over 0.06 and only 5 calls used, so AMEND again (Flash-Lite).
                 After it: temp:usd about 0.069, temp:calls 6.
4. Call 7        check: temp:calls 6 >= 6, so INTERVENE. before_model_callback returns an
                 LlmResponse with partial_answer(st). No model is called.
5. The trace shows six generate_content spans, not seven, and the user gets whatever findings exist.

The model chose to keep calling the tool; the plugin, in code, chose the cheaper model at call 5 and the stop at call 7.

Where does it go in the plugin list? Part 9’s order is KillSwitch, RequestPolicy, RateLimit, ModelArmor, Audit, UsageLedger (leaving out UiActionPlugin, which is not a guardrail). The kill switch, request policy and rate limit work in before_run_callback, so they have already run before any model call. Among the model hooks, the plugins page says ADK stops at the first plugin that returns a value. I would put BudgetPlugin just before UsageLedgerPlugin, so a refused call is never logged as a model call.

Deadline propagation to tools and remote agents

A deadline is only useful if every step can see it. The SRE chapter describes deadline propagation: the first server sets one deadline, and every call it makes carries the time that is left. In its example, a 30 second deadline becomes 23 seconds after 7 seconds of work, and 19 seconds after 4 more.

A pizza shop promises delivery in 30 minutes. The kitchen takes 20, so the driver has 10, not a fresh 30 of his own. If nobody tells him, he takes the long way and arrives after the customer has given up and made toast. The promise to the customer is the deadline; telling the driver “you have 10 minutes” is propagation.

How does this apply to Acme? Maria is the customer, the router is the kitchen, and each specialist or query is a driver who today sets his own timer.

ONE DEADLINE FOR THE WHOLE TURN, PASSED DOWN AS "TIME LEFT" ROUTER SPECIALISTS (A2A) TOOLS router deadline = now + 60 s spends 7 s (extract) 53 s left 53 s left marketing agent spends 5 s thinking supply agent spends 6 s thinking 48 s left 47 s left BigQuery query timeout = min(60, 48-5) Databricks query timeout = min(60, 47-5) WITHOUT PROPAGATION Each hop invents its own limit: A2A timeout 300 s, query timeout 60 s. The turn is over at 60 s, but the supply agent can keep thinking and querying for minutes, spending tokens and warehouse time on an answer nobody will read. Keep 5 s at each level to write a reply, so a slow tool still leaves time for an honest answer.

deadline propagation: the router sets one deadline of 60 seconds for the turn, spends 7 seconds, and passes 53 seconds left to each specialist; each specialist spends its share and passes what remains to its query, minus 5 seconds to write a reply; without propagation each hop invents its own limit and work continues after the user has given up

Our build has two fixed limits that ignore the turn: a 60 second timeout on each BigQuery query (Part 3) and a 300 second timeout on each A2A call (Part 6). The fix is to cap each of them by the time left:

# Illustrative: a tool helper and an A2A metadata provider.
def time_left(tool_context, cap_s: float, reserve_s: float = 5.0) -> float:
    left = tool_context.state.get("temp:deadline", time.time() + cap_s) - time.time()
    return max(0.0, min(cap_s, left - reserve_s))   # keep time to write a reply

def request_meta(ctx, a2a_request) -> dict:
    meta = identity_meta(ctx)                       # Part 6: who is asking
    deadline = ctx.session.state.get("temp:deadline")
    if deadline:
        meta["time_left_s"] = round(deadline - time.time(), 1)
    return meta

A tool passes time_left(tool_context, cap_s=60) to its query timeout, and returns {"status": "error", "message": "no time left"} instead of running when it gets 0. request_meta is the a2a_request_meta_provider that RemoteA2aAgent already accepts; the ADK source shows it receives the invocation context and the outgoing message and returns metadata to attach. The specialist reads time_left_s as it reads the user’s identity in Part 6 and sets its own temp:deadline. Sending time left, not a clock time, means the two machines’ clocks need not agree. ADK has no built-in deadline propagation, so this is our own convention, and ADK’s A2A support in Python is still experimental as of October 2026 (Part 6).

Here is the zero case, with illustrative times. A 60 second turn has reached 56 seconds when the supply agent’s tool is about to query Databricks:

1. temp:deadline is 4 s away.
2. time_left(tool_context, cap_s=60) = max(0, min(60, 4 - 5)) = 0, because 5 s is kept in reserve.
3. The tool does not start the query. It returns {"status": "error", "message": "no time left"}.
4. The supply agent reports that it has no answer, and the router falls back to a partial answer
   (see "Stop early" below) inside the 60 s, instead of a query that finishes after Maria gave up.

The same channel carries the money. Each specialist runs in its own engine with its own plugins, so the router’s budget plugin never sees their model calls. The router sends each specialist its share of the remaining budget, and the specialist’s own BudgetPlugin enforces it.

A cheaper model when the budget runs low

The plugin above switches to a cheaper model when three quarters of the money is gone. Reading ADK Python’s source, the Gemini adapter sends whatever llm_request.model names, so changing it in before_model_callback changes the model for that one call. That is an implementation detail, not a documented promise, so test it after each ADK upgrade. Keep the switch within one model family; the request was built for Gemini.

Part 11 priced the levers. At the same token counts, Gemini 3.1 Flash-Lite would cost about 0.0033 dollars for the writer’s call instead of 0.0262 on 3.1 Pro. That saving means nothing if the answer gets worse, so the cheaper path goes through the eval gate like any other change.

Stop early, with a partial but honest answer

A partial answer is honest when it names what is missing; it becomes dangerous the moment something fills the gap with a guess. A garage that only had time to check your brakes should say “the brakes are fine, I didn’t get to the tyres”, not tell you the car is fine.

How does this apply to Acme?

When the budget runs out, the turn should still end with something true. The order I use:

  1. If findings exist, show them formatted by code (rung 4), with one line saying the answer is incomplete and why.
  2. If a specialist answered and the other did not, show the part that exists and name the part that is missing, and who owns it.
  3. If nothing exists yet, use the budget template (rung 5) and suggest a narrower question.

What the turn must never do is let the writer “summarise what we have” with no check. That is how a partial answer turns into an ungrounded one.

Here is case 2 on Part 5’s mixed question, “Compare the Acme Pure Refill Launch spend with what the stockouts cost us”, on a morning when the supply agent is mid-deploy:

1. Routing (code): spend is local, penalties are delegated, so the target is both.
2. Marketing answers: Acme Pure spend, EUR 283,590.
3. Supply does not answer in time: failure mode 8.
4. The router does not ask the writer to compare one number with nothing. Code builds the reply:

   Here is the part I have:
   - Acme Pure spend: EUR 283,590 (marketing, campaign_performance)
   Missing: the supply side (service penalties, owned by supply and finance).
   The supply data service isn't responding right now. Please try again in a few minutes.

The last sentence is the DECLINES["unavailable"] text from rung 5. Every figure Maria sees came from a tool, and the gap is named, with its owner, so she knows what to ask for later.

How do I test fallbacks?

A fallback that has never run is only a hope. Fault injection makes it run: break one thing on purpose and check that the expected rung handled it, using Part 10’s eval cases and Part 11’s traces and usage ledger.

A fire drill is the everyday version. You do not find out whether the exits work during a real fire; you set off the alarm on a quiet Tuesday, time how long the building takes to empty, and check that nobody used the lift. The alarm is the injected fault, the planned exit route is the expected rung, and the timing sheet is the evidence.

How does this apply to Acme? Each drill becomes an eval case that runs on every change.

FAULT INJECTION: BREAK ONE THING ON PURPOSE, EXPECT ONE RUNG, CHECK THE EVIDENCE fault injected expected rung what the eval or trace checks writer gets 429 three times rung 2: fallback model answer passes number check extract returns broken JSON rung 3: one repair route_correct still 1.0 writer quotes an unknown figure rung 4: findings only "couldn't verify" + findings supply agent times out partial answer marketing part + "supply missing" budget used up at call 3 rung 5: budget decline no 4th model call in the trace Gemini returns SAFETY rung 5: safety decline template text, no retry 5 writer errors in a minute rung 6: breaker opens next turn skips the writer Each row is one eval case (Part 10). The trace and the usage ledger (Part 11) are the evidence.

fault injection: each eval case breaks one thing on purpose, such as a 429 on the writer, broken JSON from the extract step, an unknown figure, a supply agent timeout, a used-up budget, a safety block or repeated writer errors, and checks that the expected rung ran, using the eval result, the trace and the usage ledger as evidence

  • Each fault is an eval case. Next to the question and expected route, the case says which fault to inject and which rung should handle it, for example "fault": "writer_429" and "expected_rung": "fallback_model".
  • The fault lives in test-only code. A small stand-in model (a subclass of ADK’s BaseLlm, the base class for models) raises the error or returns a canned bad reply for the named agent. Part 10’s mutation tests switch off a safety control; this switches on a failure. Neither goes near production.
  • The evidence is the trace and the ledger. Code metrics check the outcome, not the wording. A budget stop at call 3 must show no fourth generate_content span in the trace and no fourth model_usage entry. A safety block must show exactly one model call, because a retry would mean the code treated a permanent error as transient.
  • Log the rung. A fallback event in the structured log, with the failure mode and the rung, lets a dashboard show how often each rung fires. A rising rung 2 count is an early warning about quota.

Here is Part 10’s q3_delegation golden case with a fault added. The two new fields are our own, like the other expected_* fields:

{
  "eval_case_id": "q3_delegation_writer_429",
  "prompt": {"role": "user", "parts": [{"text": "What did the stockouts behind the Acme Pure Refill Launch cost us?"}]},
  "expected_route": "delegate",
  "expected_target": "supply",
  "expected_numbers": [51331],
  "fault": "writer_429",
  "expected_rung": "fallback_model"
}

The case passes only if all of this is true (illustrative evidence):

1. Trace: three failed generate_content spans on gemini-3.1-pro-preview (the first try plus two
   retries), then one successful span on gemini-3.5-flash.
2. The answer contains 51331 (as 51,331), and grounded is 1.0: the number check passed.
3. The structured log has one fallback event with mode=rate_limit and rung=2.

The stand-in model raised the 429s; everything the case checks was decided by code, so the same case gives the same result on every run.

Try it: a budget-aware retry and fallback loop

This simulation needs only Python 3. It stands in for the writer step: a Pro model with retries, a Flash fallback, the number check and a budget. The prices are Part 11’s list prices and the token counts match the writer’s illustrative counts, so the costs line up with Part 11. The findings and the model replies are made up. Time is simulated, so it runs instantly. Save it as fallbacks_demo.py:

"""fallbacks_demo.py: a budget-aware retry and fallback loop, no cloud and no libraries."""
import random
import re

PRICES = {"pro": (2.00, 12.00), "flash": (1.50, 9.00)}   # USD per million tokens (in, out)
FINDINGS = {"penalties_eur": "36.0k", "stockout_weeks": "7"}


class Transient(Exception):      # 429, 503, timeouts: worth a retry
    pass


class Budget:
    def __init__(self, usd, calls, seconds):
        self.usd, self.calls, self.deadline = usd, calls, seconds
        self.spent, self.used, self.clock = 0.0, 0, 0.0

    def can_afford(self, est_usd, est_s):
        return (self.used < self.calls and self.spent + est_usd <= self.usd
                and self.clock + est_s <= self.deadline)

    def charge(self, model, tok_in, tok_out, secs):
        p_in, p_out = PRICES[model]
        self.spent += (tok_in * p_in + tok_out * p_out) / 1_000_000
        self.used += 1
        self.clock += secs


def fake_model(model, scenario):
    """Stands in for Gemini. Each scenario breaks something on purpose."""
    if scenario == "429 storm" and model == "pro":
        raise Transient("429 RESOURCE_EXHAUSTED")
    if scenario == "ungrounded":
        return "Stockouts cost EUR 52,000 over 7 weeks."
    return "Stockouts cost EUR 36.0k in penalties over 7 weeks."


def grounded(text):
    numbers = re.findall(r"\d[\d,.]*k?", text)
    return all(n.rstrip(".") in FINDINGS.values() for n in numbers)


def findings_only(header="I couldn't verify every figure, so here are the findings:"):
    rows = [f"- {k}: {v}" for k, v in FINDINGS.items()]
    return header + "\n" + "\n".join(rows)


def call_with_retry(model, scenario, budget, attempts=3, base=1.0):
    for attempt in range(1, attempts + 1):
        if not budget.can_afford(0.03, 8):
            raise TimeoutError("budget or deadline")
        try:
            text = fake_model(model, scenario)
            budget.charge(model, 5000, 1350, 6)
            return text
        except Transient as err:
            budget.charge(model, 0, 0, 1)          # a failed call still costs time
            if attempt == attempts:
                print(f"   {model} attempt {attempt}: {err}, next rung")
                break
            wait = random.uniform(0, base * 2 ** (attempt - 1))   # full jitter
            budget.clock += wait
            print(f"   {model} attempt {attempt}: {err}, waiting {wait:.1f}s")
    raise Transient(f"{model}: {attempts} attempts failed")


def answer(scenario, budget):
    for rung, model in (("normal", "pro"), ("fallback model", "flash")):
        try:
            text = call_with_retry(model, scenario, budget)
        except Transient:
            continue                     # next rung
        except TimeoutError:
            return "budget stop", findings_only("I reached this question's time or cost limit. The findings so far:")
        if grounded(text):
            return rung, text
        return "findings only", findings_only()   # never ask the model to try again
    return "decline", "The writer is unavailable right now. Please try again in a few minutes."


random.seed(7)
for scenario, budget in [("normal", Budget(0.08, 6, 60)), ("429 storm", Budget(0.08, 6, 60)),
                         ("ungrounded", Budget(0.08, 6, 60)), ("429 storm", Budget(0.08, 3, 60))]:
    print(f"== {scenario} (max {budget.calls} calls)")
    rung, text = answer(scenario, budget)
    print(f"   rung: {rung} | calls {budget.used} | USD {budget.spent:.4f} | {budget.clock:.1f}s")
    print("   " + text.replace("\n", "\n   "))

Run python3 fallbacks_demo.py. With the fixed random seed, you should see:

== normal (max 6 calls)
   rung: normal | calls 1 | USD 0.0262 | 6.0s
   Stockouts cost EUR 36.0k in penalties over 7 weeks.
== 429 storm (max 6 calls)
   pro attempt 1: 429 RESOURCE_EXHAUSTED, waiting 0.3s
   pro attempt 2: 429 RESOURCE_EXHAUSTED, waiting 0.3s
   pro attempt 3: 429 RESOURCE_EXHAUSTED, next rung
   rung: fallback model | calls 4 | USD 0.0197 | 9.6s
   Stockouts cost EUR 36.0k in penalties over 7 weeks.
== ungrounded (max 6 calls)
   rung: findings only | calls 1 | USD 0.0262 | 6.0s
   I couldn't verify every figure, so here are the findings:
   - penalties_eur: 36.0k
   - stockout_weeks: 7
== 429 storm (max 3 calls)
   pro attempt 1: 429 RESOURCE_EXHAUSTED, waiting 0.7s
   pro attempt 2: 429 RESOURCE_EXHAUSTED, waiting 0.1s
   pro attempt 3: 429 RESOURCE_EXHAUSTED, next rung
   rung: budget stop | calls 3 | USD 0.0000 | 3.8s
   I reached this question's time or cost limit. The findings so far:
   - penalties_eur: 36.0k
   - stockout_weeks: 7

Four scenarios, four rungs. The normal turn costs one Pro call. The 429 storm costs three failed Pro attempts and one Flash call, and Maria still gets the right answer. The ungrounded reply is replaced by the findings without a second call. In the last run the budget allows only three calls, so the turn stops after the third failed attempt and shows what it has. Change the jitter to a fixed wait, or raise attempts to 10, and watch the time grow.

What can go wrong

  • Retrying permanent errors. A 400 or a safety block fails the same way every time. Retrying it burns quota and time. Retry only the codes on your list.
  • Retries at every layer. SDK retries inside a tool retry inside an A2A retry multiply. The SRE book’s example turns one action into 64 attempts. Pick one layer for each kind of retry.
  • A fallback with no limit. A fallback or repair that can repeat is a loop. Use one temp: flag per rung.
  • A budget that only exists in the report. If nothing checks the budget during the turn, it only describes the past. Check it before each model call.
  • Fixed timeouts that outlive the turn. A 300 second A2A timeout inside a 60 second turn keeps a specialist working on an answer nobody will read. Propagate the deadline.
  • Counters in one instance’s memory. Like Part 9’s rate limit, a breaker kept in memory resets on restart and counts per instance. Use a shared store if it must be exact.

What code did we write in this part?

Every part ends with the same map of Acme’s build: which files exist so far, and the code this part added, explained line by line. The map grows with each part.

This part adds no code to Acme’s build. The one line it quotes from the build, the writer’s three retry attempts in router/app/writer.py, was already there, as were the number check, the kill switch and the usage ledger. The fallback model, the repair step, the circuit breaker, the budget plugin, deadline propagation and the fault-injection eval case are shown as illustrative code for the design, and the budget simulation is a standalone exercise.

Where does it live?

router/                                             the router agent: extract, route, write
  app/agent.py                                      P5,8        workflow; extract reads memory; App with compaction and resumability
  app/app_utils/services.py                         P8          picks the session, artifact and memory services
  app/fast_api_app.py                               P8          Runner with session, artifact and memory services
  app/remote.py                                     P6,7        A2A client over mTLS; metadata carries Sam's token
  app/writer.py                                     P5,8        writer reads memories and saves the conversation to Memory Bank
  scripts/create_memory_bank.py                     P8          creates the Memory Bank and its topics, once
  tests/eval/acb_metrics.py                         P10         code metrics: route_correct, grounded, golden_numbers
  tests/eval/build_datasets.py                      P10         splits source cases into golden and held-out, seed 42
  tests/eval/calibrate.py                           P10         compares judge scores with human labels (kappa)
  tests/eval/datasets/golden_marketing.json         P10         golden cases with expected route, target and numbers
  tests/eval/eval_config.yaml                       P10         eval metrics, including the faithfulness judge
  tests/eval/gate.py                                P10         thresholds per metric; exits 1 when one fails
  tests/unit/test_router.py                         P10         unit tests; routing checked with one row per question type
marketing/                                          the marketing specialist, on BigQuery
  app/agent.py                                      P1,2,3,4    marketing agent with its skill in a SkillToolset
  app/app_utils/a2a.py                              P6          A2A endpoint and agent card (1.0 and 0.3) for marketing
  app/skills/marketing-kpis/SKILL.md                P4          marketing know-how: which tool, definitions, story checks
  app/skills/marketing-kpis/references/tables.md    P4          tables, grain, measures and the join rule, read on demand
  app/tools.py                                      P1,2        three narrow tools: measures, unserved demand, SQL fallback
  tests/unit/test_marketing.py                      P2          unit tests for the tools, with a fake context and database
supply/                                             the supply specialist, on Databricks
  app/agent.py                                      P3,7,8,9    supply agent and App with the confirmed replan tool
  app/app_utils/a2a.py                              P6          A2A endpoint and agent card (1.0 and 0.3) for supply
  app/skills/supply-catalog/SKILL.md                P4          supply know-how, given only to the supply agent
  app/tools.py                                      P3,7,9      supply tools; the replan tool asks a human first
shared/                                             code all three services share
  adk_common.py                                     P3,6,9,11   plugins and redacted JSON logs, now with the usage ledger
  auth_manager.py                                   P7          auth manager callback: token or consent (off by default)
  engines.py                                        P3,7        run_databricks connects as the signed-in user
  factcheck.py                                      P5          ungrounded_numbers: finds figures not in the findings
  registry.py                                       P2          reads semantic.yaml: measure names, computable measures
  routing.py                                        P1          classify: picks the specialist in code, no model
  safety.py                                         P9          per-user token bucket and injection pattern scan
  semantic.yaml                                     P1,2        registry of measures, owners and published formulas
  skill_source.py                                   P4          loads a skill from disk or a pinned registry revision
  sqlguard.py                                       P3          guard_sql read-only allow-list; redact for log lines
  tests/test_cost_report.py                         P11         unit tests for the cost maths
ops/                                                build, deploy and evaluation tooling
  .cloudbuild/eval_gate.yaml                        P10         after staging: eval run, then gate.py
  .cloudbuild/pr_checks.yaml                        P10,11      offline PR checks, cheapest first, ending with the mutants
  env.sh.example                                    P11         settings for every engine: ids, models, plugin switches, OTEL; no secrets
  tools/cost_report.py                              P11         prices the ledger and reports cost per resolved question
  tools/mutants/acb_mutate.py                       P10         pytest plugin that injects each mutant's fault
  tools/pricing.json                                P11         list prices per model, kept out of the code
  tools/run_mutants.py                              P10         runs the offline suites once per mutant

+ added in this part   ~ changed in this part   Pn = the parts that touched the file

The post names the gap plainly: the build does not yet have the budget plugin, the circuit breaker or deadline propagation.

What did we learn?

  • Timeouts, rate limits and server errors are transient and may be retried; bad output, safety blocks, tool errors, ungrounded answers and remote agent failures each need their own response.
  • A deterministic fallback is a response chosen in advance and written in code for each failure mode, never “ask the model again and hope”.
  • The fallback ladder runs from a bounded retry with backoff and jitter, through a fallback model, one repair, an answer from tool results and a templated decline, down to a circuit breaker and the kill switch.
  • An execution budget limits one request’s tokens, cost, model calls and time, and is checked before each model call while the turn runs.
  • In ADK, max_llm_calls is only a safety net; a plugin’s before_model_callback enforces the real budget, and one deadline is passed down to every tool and specialist.
  • Fallbacks are tested by injecting faults in eval cases and checking the trace and usage ledger for the expected rung.

What comes next?

The system now fails in ways we chose: every failure mode has a rung, every turn has a budget, and every fallback has an eval case that proves it runs. That is what makes it safe to hand to people who did not build it. First, Part 11.75 explains what Gemini Enterprise is and untangles its vocabulary: connectors, skills, the two meanings of plugin, and sandbox-based agents. Then in Part 12, giving it to users, we publish the router to Gemini Enterprise, handle user consent, control which endpoints the agents may reach with Agent Gateway, and roll out in stages: shadow mode, a small group, then everyone.

Sources

Opinions are my own. Every fact in this post comes from the public documentation linked above. Acme Consumer Brands and its data are fictional, and the costs, token counts and times are illustrative, not measurements.