AI agents

Testing That a Cap Fires: Fixtures for the Refusal Path

Antonio Evans7 min read

A split scene: on the left, a test harness with labeled fixtures (memory, concurrency, duration, cost) each showing a red 'cap exceeded' trigger; on the right, a magnifying glass over a refusal object (code, message.
Key points

Key points

  1. 1A cap is proven only by a test that makes it fire, so every placement needs at least one fixture that exceeds its limit and asserts the refusal.
  2. 2The refusal path has two halves to check: the refusal object returned to the caller, and the log row written with the same correlation ID.
  3. 3Provider-side limits and gateway-side limits are measured differently, so a single fixture cannot stand in for all of them (token budgets are often checked after the response, not before).
  4. 4Soft alerting and hard caps fail in different ways, and the fixture you write for a hard cap must assert that no billable call left the process.

Start from the failure you are preventing

Most cost-control code is written to be invisible. A budget check sits on the request path, returns early when spend is under the threshold, and the interesting branch, the refusal, runs only when money is already gone. That branch is where the bugs live: a missing error code, a message that leaks a key prefix, a log line written before the correlation ID is attached.

The reason to care is scale of failure, not elegance. One team's internal report describes a pre-production worker loop that ran a default high-capability model for eight days and produced $7,152.89 in charges against a normal monthly baseline of about $11, driven by 1.14 billion tokens from a single loop (softwarepricing.com). A cap that was never exercised is not protection against that shape of incident. This is the same argument testers have made about rate limits for years: a test that calls a service until it throttles is only worth writing if you care about the result and the result changes your confidence (Stack Exchange).

The decision rule for this article: write a refusal fixture for every cap on the request path that can stop, degrade or reroute a call. If a limit only produces a dashboard line, it gets a different test (see the comparison table below).

Four placement types and what a fixture must push past

Placements differ in what you can actually control from a test, so each fixture needs a different lever.

PlacementLimit unitFixture leverRefusal signal to assert
Memory / contextTokens in a prompt or retrieved chunk setBuild an input at limit + 1, measured with the model's own tokenizerError code, measured token count, suggested max input
ConcurrencyIn-flight requests, threads, agent stepsHold N permits open, then issue request N + 1Error code, limit name, retry hint with hold time
DurationWall clock, session length, stream timeInject a fake clock and advance past the ceilingError code, elapsed value, refresh/reset time
CostDollars or credits per key, team or windowSeed spend to limit minus one unit, then spend twoError code, remaining budget, reset window
Token rateTokens per minute across a fleetSimulate a completed response reporting usage above the windowNext request's 429 and the Retry-After value

Two rows deserve emphasis. Concurrency fixtures are the ones people skip, because holding permits open feels artificial, but reservation-based limiters exist precisely to close the race where many concurrent requests each pass a zero-cost check before any of them reports usage (Kuadrant docs). Cost fixtures are the ones people get wrong, because the cap is often enforced against spend read from a database.

Sizing token and cost fixtures so they don't drift

An input full of filler words is not a token fixture. If your fixture says "this prompt is 8,192 tokens" and the real tokenizer says 9,500, the test passes while production overflows. Use the model's own tokenizer where you can: tiktoken mirrors the byte-pair encoding used by OpenAI models, and one worked example shows that the characters-divided-by-four heuristic estimated 8 tokens for a string that actually encoded to 11, a 37% miss (galileo.ai). That error direction matters: heuristics can undercount, so a fixture built on one can sit under the limit and never fire.

Treat the same caution for chunked retrieval inputs. The token limit applies to the assembled prompt, so the fixture must total system prompt, retrieved chunks and expected output, not just the chunks you control (MongoDB, LangChain). Microsoft's documentation is a useful reminder that the hidden system prompt can be substantial, roughly 1,200 tokens by their estimate, before your own content is counted (Microsoft Learn).

For cost fixtures, work in fractions of the limit rather than a dollar figure copied from a console. A fixture that seeds spend to max_limit - one_input_unit and then submits a request whose known input cost is two units will fire, and it will keep firing when prices change. Price tables move; the ratio does not.

What the refusal object must contain

Your fixture should assert the refusal object field by field. Minimum viable shape, in language-neutral terms:

{
  "ok": false,
  "error": {
    "code": "BUDGET_EXCEEDED",        // stable, machine-readable, documented
    "limit": "team_monthly_cost",      // which placement fired
    "observed": 412.60,                // the value at decision time
    "threshold": 400.00,               // configured limit
    "retryable": false,                // or a retry hint
    "retry_after_seconds": 86400,      // only when retryable is meaningful
    "correlation_id": "8f2c..."         // same id as the log row
  }
}

Four assertions you should make and three you should refuse to make:

  • Assert the code is a stable string from a documented set, not a free-form message.
  • Assert the limit name identifies the placement, so an operator can tell a per-key cap from a team cap. Team and member budgets are enforced at different scopes in practice, and a key that belongs to a team resolves against team budgets rather than personal ones (LiteLLM).
  • Assert the retry hint is present and consistent with the reset window, since teams do distinguish "stops serving" from "notifies" and "degrades instead" (nhimg.org).
  • Assert the correlation ID appears in both the object and the log.
  • Do not assert the dollar value exactly; assert the relationship to the threshold.
  • Do not assert on a message's wording unless the message is part of your public contract.
  • Do not assert a status code alone.

Status codes matter for the transport layer, not the logic. A 429 without a Retry-After header is a cap that tells the caller nothing, and header-level checks belong in the same test as the object-level ones (agentgateway, Beeceptor).

The log row is half the test

A refusal that is not logged is indistinguishable from a crash during an incident review. The assertion is mechanical: after the call under test returns, fetch the log row by correlation ID and compare a fixed set of fields against the refusal object.

def test_team_cost_cap_writes_matching_log(db, client, team_id, correlation_id):
    seed_team_spend(db, team_id, limit=400.00, current=399.50)

    resp = client.post("/v1/complete", json=oversized_input,
                       headers={"x-correlation-id": correlation_id})

    assert resp.status_code == 402
    body = resp.json()
    assert body["error"]["code"] == "BUDGET_EXCEEDED"
    assert body["error"]["threshold"] == 400.00

    rows = db.query("select * from request_log where correlation_id = %s", correlation_id)
    assert len(rows) == 1
    row = rows[0]
    assert row.outcome == "refused"
    assert row.error_code == body["error"]["code"]
    assert row.limit_name == body["error"]["limit"]

The field comparison is what catches drift. Teams that log cost per request and expose a log store with full cost data already have the raw material (dev.to); the fixture's job is to prove the refused path writes a row too, with the same ID and the same code. If your gateway logs refusals with a different ID generator than the one returned to the caller, this test is the only place that will ever notice.

A worked example of why the log assertion is worth it: an operator filters logs by correlation ID from a customer ticket, expects one refused row, and finds none. Without the test, the gap surfaces only when someone is already reconciling a bill.

Firing a cap is not the same as stopping the spend

The fixture lives in your process. The bill does not. A hard cap that returns a refusal while an upstream request is already in flight may still be charged for that request, and provider-side token budgets often count after the response, meaning the request that pushes you over completes and the next one is rejected (agentgateway). Local rate limiting on one replica also keeps counters independent of other replicas, so a fixture that proves one process refuses says nothing about the fleet (agentgateway).

So pair the object assertion with a second one: assert that the call under test never reached the provider adapter. In practice, inject a fake adapter that records calls and assert the recorder is empty on the refusal path. That single assertion is the difference between testing the conditional and testing the cap.

Global limits add a fleet dimension that a unit fixture cannot cover. That is what an integration test is for, and it deserves a separate, slower test with a shared counter store, not a fixture that pretends a local counter is global (Gravitee, MLflow).

Building the fixture set in a morning

Order of work matters more than tooling. Do it in this sequence.

  1. List every placement on the request path and the exact unit it caps. If you cannot name the unit, you cannot build the fixture.
  2. For each placement, write the seed step first. Seeding is where most fixtures actually fail, since spend caps read from a store and memory caps read from the assembled prompt.
  3. Write the over-limit input using the real tokenizer or a real spend increment, and assert the measured value in the test so a later tokenizer or price change fails loudly.
  4. Assert the refusal object, comparing fields against the configured limit rather than hard-coded numbers.
  5. Re-fetch by correlation ID and compare the log row field by field.
  6. Assert the provider adapter recorded zero calls.
  7. Add one negative fixture per placement: a request exactly at the limit must succeed. Without it, a cap set to zero by accident passes every test you just wrote.

A checklist for review:

CheckWhy it fails in practice
Fixture measures tokens with the production tokenizerHeuristic undercounts, limit never fires
Fixture seeds spend from the real storeIn-memory seed does not reach the check
Refusal object compared to configured thresholdHard-coded numbers rot
Log row fetched by correlation ID, not by recencyConcurrent tests interleave rows
Provider adapter asserted emptyRefusal returned after the call was made
At-limit success case existsA zero or misconfigured limit passes silently
Retry hint asserted against the windowHeader added later breaks clients unnoticed

Two rows are worth singling out. Fetching the log row by recency works perfectly until your suite runs in parallel, then it flakes and gets deleted. And the at-limit success case is the one that catches the opposite bug: strict-greater comparisons that refuse the last allowed unit.

If your caps are enforced inside a gateway you do not control, the same fixture pattern applies one layer up, against a mock that replays the gateway's refusal. Container queues, image tokens and long contexts also change the size of a single request, so fixtures that assume text-only input will understate consumption (Microsoft Learn, LLM API).

When the caps span several providers and bill shapes, the fixture set grows with each adapter. Consolidating text, image and audio calls behind one key that is already metered removes some of that adapter surface, which is the practical argument for Quanta Inference (https://quantaintelligence.ai/inference) as the single place where your fixtures seed and assert.

FAQ

FAQ

Why not just test that the limit is configured?

Because configuration is a value, and a cap is a behavior. Tests that read back a config file prove the number was written, not that the branch which compares spend against it is reachable. The only assertion that proves the cap is one that observes the refusal and its log row.

Should the refusal test be a unit test or an integration test?

Both, at different costs. The unit fixture proves the comparison, the refusal object and the log row, and runs fast enough for every commit. A slower integration test against a shared counter store is the only way to catch fleet-level behavior such as per-replica counters that let a distributed workload exceed a nominal cap (agentgateway).

How do I test a spend cap without hitting a payment provider?

Replace the spend store and the provider adapter with fakes that implement the same interfaces. Seed spend in the fake store to one unit below the limit, submit a request with a known cost of two units, and assert both the refusal object and that the fake adapter recorded no calls. The dollar amounts never need to leave the test process.

What if the limit is enforced after the response, not before?

Then your fixture must assert the documented behavior honestly: the over-limit request completes and the next one is refused. Write two tests, one for each request, and name them so nobody later assumes the first request was blocked.

How large should the fixture's over-limit input be?

Just past the boundary, not dramatically past it. A fixture at limit plus the smallest unit the code can distinguish gives a clear failure message when the comparison operator changes, and it does not depend on provider pricing or model context sizes.

Sources

Sources

  1. AI Cost Control Architecture: Hard Caps vs Budget Alerts Guide
  2. Should I write an integration test that verifies rate limits?
  3. Token-Based Rate Limiting (for AI Workloads)
  4. How Tiktoken Stops AI Token Costs From Exploding in ...
  5. RAG with MongoDB / Describe Chunking Strategies
  6. Splitting by token - Text splitter integration guide
  7. Prompt tokens
  8. Budgets, Rate Limits
  9. How do organisations decide between soft alerts, constrained mode, and ...
  10. Rate limiting for LLMs
  11. Simulate API Rate Limits
  12. LLM Cost Tracking and Spend Management for ...
  13. API Rate Limiting at Scale: Patterns, Failures, and Control ...
  14. Stop 429s, 15% Token Drift: Gateway LLM Rate Limits for ...
  15. AI Spend Management & Cost Control
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