Development

Testing a Fallback Ladder Without Spending on the Top Rung

Antonio Evans10 min read

A four-step ladder drawn as a side view.
Key points

Key points

  1. 1Stub the cheap tiers with fixed, schema-shaped responses so every run exercises the routing logic at zero marginal cost.
  2. 2Sandbox the handoff by injecting realistic failures (timeouts, 429s, truncated JSON) and asserting the next rung returns a parsable object.
  3. 3Put the expensive rung behind a cap (once per pipeline, once per hour) so it stays rare and the bill stays predictable.
  4. 4For each rung, assert four things: it parses, it has required keys, it fits the caller's timeout, and its token count fits the budget.

Why a ladder needs its own test shape

A fallback ladder is a routing decision wrapped in a retry loop. You have a primary model, one or two cheaper or different models behind it, and maybe a deterministic path at the bottom. Most suites test the primary model's happy path and stop. The downgrade path, which is the code that actually runs on a bad day, goes unexercised until the bad day.

The reason is easy to understand. Tests that call a real, metered, non-deterministic API are slow, cost money on every run, flake when the provider has a bad day, and return different output for the same input, which is exactly the combination that makes a reliable suite impossible as one practitioner describes the problem. So teams either skip the ladder or mock so aggressively that the test verifies nothing.

A ladder has a property that makes it easier to test than a single model call: each rung is a boundary with a known contract. If rung 2 returns a string that is supposed to be JSON with three keys, you can test that contract with a stub. The expensive rung has the same contract, so the same assertions apply. The only difference is how often you are willing to pay to run it against the real thing.

The decision rule for the whole article is this: pay for a rung in proportion to how often it fires in production, not how important it is. The cheap rungs fire constantly, so test them constantly with stubs. The top rung fires rarely, so test it rarely with real calls, and test its contract constantly with stubs.

Stub the cheap tiers with deterministic responses

A stub returns predefined responses to specific inputs, while a mock additionally verifies interactions per the WireMock glossary. For a ladder you want mostly stubs and a few mocks. You want the stub to prove that a downgrade produces a parsable answer. You want a mock only when the question is "did the router call rung 2 after rung 1 failed?"

The key upgrade over a naive mock is that the stub should be a small deterministic engine, not a string that says "mocked response". In the sandbox described above, the fake completion function extracts the most relevant sentence from any context block in the prompt, and the fake tool call selects a tool by matching the goal against tool names and fabricates schema-valid arguments from each tool's JSON schema. That is the shape you want: a stub with real structure, so the code downstream gets something realistic to parse.

For a ladder, build one stub per rung, keyed by rung name, and have each stub return the same envelope your real rung returns:

LADDER = [
    ("primary",   {"text": "primary answer",   "model": "primary",   "tokens": 120}),
    ("secondary", {"text": "secondary answer", "model": "secondary", "tokens": 110}),
    ("tertiary",  {"text": "tertiary answer",  "model": "tertiary",  "tokens": 95}),
]

def stub_rung(name, prompt):
    for rung, payload in LADDER:
        if rung == name:
            return payload
    raise KeyError(f"no stub for rung {name}")

Two properties matter here. First, the stub returns a dict, not a string, so the test asserts on parsed structure rather than on text you would have to reparse. Second, the token count is included, because the token budget assertion needs a number to check. Real providers report usage on every response, and embedding responses carry token counts the same way per the Pydantic AI embeddings result, so it costs nothing to include the field.

Keep unit and integration tests structurally separate so the stub-based ladder tests run on every commit and the real-call tests run on a schedule. The common pattern is separate directories, with pytest invoked on tests/integration only in the scheduled job as described for pytest projects. That separation is the same technique cloud-init uses: a subset of integration tests carry a @pytest.mark.ci marker and run on pull requests, and most new tests should not use that mark, so a green CI run does not mean every integration test passed per the cloud-init integration testing docs.

The assertion shape per rung

Write one parametrized test that runs every rung through the same four assertions. The rung name is the parameter and the stub supplies the payload.

AssertionWhat it catchesFailure example
Response parsesA downgrade returns a string instead of an objectRung 2 returns prose, caller calls the JSON parser and gets a decode error
Required keys presentA cheaper model drops a field the caller depends ontokens missing, cost tracking crashes
Fits caller timeoutRung latency budget was never definedRung 3 takes longer than the request deadline, caller gets a 504
Fits token budgetPrompt grows and the cheap rung silently truncatesContext stuffing pushes input past the rung's window

The timeout assertion needs a per-rung budget, not a global one. A cheaper model is usually faster, but a fallback that retries three times before giving up is not, and the caller's deadline covers the whole retry chain. Assert on the summed budget for the worst-case chain, then assert each rung's own observed time from the stub against its slice.

import time, pytest

@pytest.mark.parametrize("rung", ["primary", "secondary", "tertiary"])
def test_rung_contract(rung, deadline_ms=800):
    start = time.monotonic()
    out = stub_rung(rung, "reset my password")
    elapsed = (time.monotonic() - start) * 1000

    assert isinstance(out, dict)
    assert {"text", "model", "tokens"} <= out.keys()
    assert out["text"]
    assert elapsed < deadline_ms
    assert out["tokens"] <= input_token_budget(rung)

Token counting belongs beside prompt construction, not in the test. In the cost budget guide's Vitest example, the test calls the application's own message-building function and counts tokens with tiktoken before making any live call, so prompt growth is caught without spending money per the LLM cost budget testing guide. The same principle applies here: if your test rebuilds the prompt differently from production, the budget assertion measures the wrong thing.

Sandbox the handoff so downgrades get realistic failures

A ladder is only as good as its failure detection. If rung 1 returns HTTP 200 with a body your parser rejects, does the router fall through to rung 2, or does it return the garbage? That question is answered in the handoff, and the handoff is where most ladder bugs live.

The handoff is the boundary where one rung's output becomes the next rung's input, or where a failure signal becomes a retry decision. Sandbox it by running the router against a fake provider that can be told to fail in specific ways. The failure modes worth covering:

  • Hard errors: connection reset, 500, 503.
  • Rate limits: 429 with a retry-after header.
  • Timeouts: no response within the rung's slice.
  • Malformed success: 200 with truncated JSON, or a completion that is valid text but not valid JSON.
  • Schema drift: 200 with valid JSON missing a required key.

The fourth and fifth are the ones a naive test misses, because they look like success until the parser runs. Strict schema validation turns a missing key into a predictable validation error you can assert on, instead of a KeyError three layers up.

A related trap: a sandbox is a compute boundary, not an authority boundary. The failure mode when an agent's output reaches a trusted system is that the downstream component treats the output as trusted input and executes it without a second control per the NHI sandbox analysis. The same logic applies to your ladder: if the router treats a malformed rung response as valid because it came from a 200, the trust chain is broken at the router. Assert that the router validates, not just that it receives.

Because ladders often sit on top of retrieval, isolate the retrieval state per test run too. A shared staging index lets two experiments contaminate each other's vectors, and rollback does not fix it because the data is already written, which is why per-pull-request preview environments with a fresh database are the recommended pattern per the RAG CI guidance on Render. If your ladder's rungs differ in retrieval strategy or embedding model, that isolation matters even more, since changing the embedding model alone shifts every vector underneath you and a pinned local model is the only way to get the exact same embeddings forever as the local embedding guide notes.

The handoff test: failure in, parsable object out

The assertion that matters most is the one the research calls out directly: when a higher rung fails, the fallback must return a parsable object, never an exception.

@pytest.mark.parametrize("failure", [
    TimeoutError("rung 1 timed out"),
    HttpError(429, retry_after=2),
    MalformedResponse('{"text": "cut off'),
    SchemaDrift({"model": "primary"}),
])
def test_downgrade_is_parsable(failure):
    provider = FakeProvider(fail_on="primary", with_error=failure)
    result = run_ladder("reset my password", provider=provider)

    assert isinstance(result, dict)
    assert result["model"] == "secondary"
    assert result["text"]
    assert "error" not in result or result.get("degraded") is True

The degraded flag is worth adding if your caller needs to know it got a cheaper answer. It also gives you a metric: count degraded responses in production, and that count tells you how often rung 2 really fires. Without it, you are guessing, and the guess usually leads to over-testing the top rung.

Run this handoff test with stubs on every commit. It costs nothing and it is the test that would have caught the last three downgrade bugs.

Cap the expensive rung on a schedule

Now the expensive rung. It needs real calls sometimes, because a stub cannot tell you that the provider deprecated a parameter or that latency tripled. But it should not run on every commit.

Think of it as three layers, from most to least frequent:

LayerFrequencyUses real top rungPurpose
Contract tests (stubs)Every commitNoParsing, keys, budgets on all rungs
Handoff tests (sandbox)Every commitNoDowngrade returns parsable object
Live top-rung testOnce per pipeline or per hourYes, cappedProvider reality check

The cap is the point. Decide a budget in calls per day, then enforce it in the test harness itself, not in a comment. A simple counter keyed on the date is enough:

from datetime import date

DAILY_LIVE_CAP = 5

def live_budget_left(state):
    today = date.today().isoformat()
    if state.get("day") != today:
        state.update(day=today, used=0)
    return DAILY_LIVE_CAP - state["used"]

If the budget is exhausted, the test skips with a visible reason rather than failing or silently passing. Mark it so it is easy to select or deselect. Custom pytest markers are the standard mechanism: @pytest.mark.live on the test, and pytest -m live or -m "not live" to choose the set per the pytest marker documentation.

The scheduled job is a separate CI stage, not a faster version of the unit stage. Cloud-init's split is a good template: the pull-request run gets a small marked subset and the full suite runs elsewhere per the cloud-init docs. For the live top rung, run once per pipeline on the main branch and once on a timer, and let the daily cap absorb accidental reruns.

Worked example: the cost of over-testing the top rung

Take a suite with 40 ladder tests. Suppose 36 run through cheap rungs and 4 hit the top rung with real calls, and the suite runs on every push. A team pushing 20 times a day runs 80 real top-rung calls a day, or about 2,400 a month, before anyone notices.

Now apply the split. The 36 stub tests still run on every push. The 4 live tests move to a capped job at 5 calls a day, or about 150 a month. Same coverage of the downgrade contract, and a top-rung bill that is roughly one sixteenth of the original. The numbers are illustrative, but the ratio holds: the cap is what turns an unpredictable cost into a fixed one.

A statistical gate is worth adding on top. Compare the live top-rung results against a rolling baseline and block on a significant drop, rather than on a single failed run, because generated answers vary between runs and a single flake should not page anyone per the RAG gate guidance. Also fail on cost, not just quality: assert that the request that ran through the top rung stayed under its per-request estimate, the same way a prompt budget test asserts a dollar ceiling before any live call per the cost budget guide.

Choosing a ladder shape that is cheap to test

The test strategy is easier if the ladder itself is designed for it. Two rules do most of the work.

Give every rung the same response envelope. If rung 1 and rung 3 both return {text, model, tokens, degraded}, one parametrized test covers both. If each rung returns a slightly different shape, you write one test per rung and they drift.

Put the cheapest deterministic path at the bottom. A rule-based or hash-based retrieval step at the bottom of the ladder is testable with zero API calls and behaves identically every run. Deterministic retrieval, which skips embeddings and fetches through structured mechanisms, is worth considering for precision queries and for exactly this reason per the semantic versus deterministic comparison. A hash embedder that returns the same vector for the same text every time is another deterministic floor: no model download, no API key, and the same output in CI as on your laptop per the AI integration sandbox writeup. Hash embedding layers exist as ready-made implementations if you want a fixed-size table rather than a full model, and setting the Python hash seed gives perfect reproducibility in tests per the Hash-Embeddings repository.

If your rungs are served through one gateway, the routing config becomes the single place the ladder is defined, and the test suite can read it instead of hardcoding rung names. That is one of the practical arguments for putting every model behind one API rather than wiring each provider separately. Quanta Inference does exactly that, with text, image, video, audio and embedding models through one OpenAI-compatible key and prepaid US dollar pricing, which makes a capped live test easy to budget for. Quanta Inference.

A checklist before you ship the ladder

Run this list against your suite once, then once a quarter.

  • Every rung has a stub, and the stub returns the production envelope, not a string.
  • One parametrized test asserts parse, keys, timeout and token budget for every rung.
  • The handoff test injects at least four failure modes, including malformed 200 responses.
  • The downgrade assertion checks the return type is an object and includes a degraded flag if the caller cares.
  • Live top-rung tests carry a marker and read a daily cap from state.
  • The scheduled job runs the full suite separately from the pull-request job.
  • Cost is asserted, not just quality: a prompt that doubles context should fail a test.
  • The rung that fires most in production has the most test coverage, not the rung that costs the most.

The last point is the one teams get wrong. Coverage should track frequency of use. Your top rung is rare by design, so it deserves a real call occasionally and a contract stub always, nothing more.

FAQ

FAQ

How do I test a fallback ladder without calling the expensive model?

Stub every rung with a fixed, schema-shaped response and run the full parametrized contract suite on every commit. Put the real top-rung call behind a marker and a daily cap, and run it once per pipeline or on a timer. You get the downgrade coverage every time and the provider reality check on a schedule.

What should each rung's test assert?

Four things: the response parses into an object, the required keys are present, the call fits the caller's timeout slice, and the token count fits the rung's budget. Add a degraded flag assertion if the caller needs to distinguish a fallback answer from a primary one.

How do I test that a downgrade returns a parsable object?

Sandbox the handoff with a fake provider that fails the primary rung in realistic ways, including a 200 with malformed or schema-drifted JSON. Assert the router returns a dict, that the model field names the next rung, and that no exception escapes. That single test catches the most common ladder bug.

How often should the expensive rung run in CI?

Often enough to catch provider changes, rarely enough to keep the bill fixed. A cap of a handful of real calls per day, enforced in test state and skipped with a visible reason when exhausted, keeps cost predictable. Run it once per main-branch pipeline and once on a timer.

Should I use mocks or stubs for a fallback ladder?

Stubs for the responses, mocks only for routing assertions. Stubs supply predefined responses for specific inputs, while mocks verify that the dependency was called correctly per the WireMock comparison. Most ladder tests need the stub; the mock is for the one test that proves rung 2 was called after rung 1 failed.

Sources

Sources

  1. Building an AI Agent You Can Test in CI, With Zero API ...
  2. What is a Stub? Stubbing in Programming & Testing
  3. Embeddings | Pydantic Docs
  4. How to keep Unit tests and Integrations tests separate in ...
  5. Integration Testing, cloud-init 22.1 documentation
  6. LLM Cost Budget Testing in CI Guide
  7. What fails when an AI agent can influence trusted systems ...
  8. RAG CI/CD: Test and Gate AI Apps in Preview Environments
  9. Best Local Embedding Models to Run Without an API - AI/TLDR
  10. Working with custom markers
  11. Deterministic (Vectorless) vs Semantic (Vector) Retrieval in ...
  12. Hash-Embeddings/README.md at master
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles