AI agents

Schema Tests for Tools: Fixtures That Assert Every Error Code

Antonio Evans7 min read

A workbench with a row of small labeled trays, each holding a single paper slip.
Key points

Key points

  1. 1Define one fixture per error class that captures the complete response envelope, not just the message string.
  2. 2Each fixture asserts three things: the envelope has the documented fields and types, the hint string matches the error code exactly, and the retryability flag equals the documented boolean.
  3. 3Store fixtures next to the tool code and run the same set against the live tool in CI so any drift in codes, shape or retry semantics fails the build.
  4. 4Treat the error contract as reviewed production code: test the fixtures themselves with known-good and known-bad cases before trusting them.

Why the failure contract drifts

A tool specification is the contract between your model and your code: it says what the tool does, what inputs it accepts and what shape those inputs follow the tool specification discussion. Most teams test the happy path of that contract and leave the failure path unexamined. That is where drift lives.

Drift is rarely a deliberate breaking change. It shows up as a validation error that used to return hint: "INVALID_DATE" and now returns hint: "validation_error", or a transient timeout that gets reclassified as permanent because someone added a broad except block. Retrying a permanent failure wastes time and resources, because a malformed request body or a revoked key will be rejected identically on every attempt Temporal's non-retryable errors pattern.

The same problem exists at the platform level. In one reported case, a response reader threw a network error that a shared classifier already recognized as transient, but the catch path only routed one specific exception type into bounded recovery, so the turn was terminalized instead of retried the Cloudflare agents issue. That is a retryability flag bug wearing a different costume. A fixture asserting retryable: true for that class would have failed the build.

The three assertions every error fixture needs

A fixture is a recorded request plus the exact response it produced, stored as data. The test replays the request and checks three things.

1. Envelope shape. The response must have the documented fields with the documented types. Keep this separate from business rules: schema validation proves consumers can safely parse the body, it does not prove the values are correct the JSON Schema testing guide. Use a strict validator, set additionalProperties: false in the error schema, and declare the dialect in the root schema so a keyword that means one thing in one draft is not silently reinterpreted in another.

2. Hint identity. The machine-readable hint must equal the expected error code exactly. Equality, not a substring match. Models use structured errors to self-correct: an LLM told "invalid date format, expected ISO 8601" can fix its next call, while a raw stack trace usually cannot error handling and recovery patterns. Precise hints are the input to that loop, so an inexact hint is a functional regression.

3. Retryability. The flag must equal the documented boolean. Classify at the tool-definition level into transient, terminal and needs-clarification so the control loop branches correctly instead of guessing error handling and recovery patterns. Practical classification rules exist: 429, 500, 502, 503 and 504 are transient, while malformed prompts, exceeded context windows and invalid keys are permanent agent error handling patterns. For 5xx codes on non-idempotent operations, the safer rule is to require an idempotency key before any retry is allowed at all whether to retry 500 errors.

AssertionWhat it catchesFailure signature
Envelope shape and typesField renames, missing keys, type changesSchema validation error naming the path
Exact hint equalityRenamed or reused error codesexpected "INVALID_DATE", got "validation_error"
Retryability flagTransient errors marked terminal and vice versaBoolean mismatch, with the class named
Schema of the schemaA validator that passes everythingKnown-bad fixture accepted

Where to keep fixtures and schemas

Keep fixtures in the tool's own package: tools/<tool-name>/fixtures/errors/<code>.json plus schemas/error-envelope.json. The schema describes the public representation of the error, never a database row or ORM type, so an accidental data exposure does not get normalized into the expected output the JSON Schema testing guide.

There is a useful shortcut for building the schema. Record one or two real responses, convert them to a schema, and clean up the result: a single response rarely infers optional fields correctly, and you should add formats for emails, URLs and identifiers using JSON Schema for third-party response validation. Then test the schema itself, because a schema that validates everything is worse than no schema. Keep a known-bad fixture per error class in a negative/ folder and assert that the validator rejects it.

Two more rules keep the set honest. First, a fixture belongs to a contract version: when you change an error code on purpose, update the fixture in the same pull request, so the fixture diff is the contract diff and reviewers see it. Second, do not try to do this with end-to-end tests against a third-party API. That is slow, depends on outside servers and is close to impossible to set up with useful fixtures the same writeup.

Build the harness: a small classification gate

The harness needs two layers. Unit tests replay fixtures against the tool's error constructor in-process, which is fast and runs on every commit. Integration tests call the live tool behind a feature flag, which is slower but catches wiring problems.

Start with the classifier, because every fixture depends on it. A minimal contract:

TRANSIENT = {429, 500, 502, 503, 504}

def classify(status=None, exc=None):
    if status in TRANSIENT:
        return {"hint": "TRANSIENT", "retryable": True}
    if exc and "timeout" in str(exc).lower():
        return {"hint": "TIMEOUT", "retryable": True}
    return {"hint": "PERMANENT", "retryable": False}

The test replays each fixture's input through the tool, then asserts the three properties. Run schema conformance with a strict validator and fail on the first violation, printing the violated path and the actual response, because that turns an opaque "must have required property" message into a fast diagnosis the JSON Schema testing guide. Zod and native matchers such as expect.any cover simpler cases, but for a multi-field envelope a real JSON Schema validator is less typing and stricter Playwright schema validation walkthrough.

Wire it into CI

Run the unit layer on every pull request, and the live layer on a schedule plus before deploy. The important part is the gate: the build fails when the fixtures fail, not when a human notices in a dashboard.

Practical CI steps:

  1. Lint the schemas. Vendor the validator and run it with a fixed configuration so a validator upgrade cannot silently change dialect behavior.
  2. Replay every fixture. Assert shape, hint and retryability, and treat a missing fixture for a known error class as a failure.
  3. Run the negative fixtures. If a known-bad envelope passes, the schema is permissive and the build stops.
  4. Report drift. Print expected versus actual for all three assertions in one message, grouped by error class.

Contract testing tools follow the same shape at the deployment boundary. can-i-deploy blocks a deployment when verification between the version you are deploying and its counterparts has not succeeded, and it can poll for results that are still unknown rather than failing immediately resilient builds with can-i-deploy. Your error fixtures are the same idea one level down: they are the check that says the failure contract still holds before the tool ships.

If your tools call several providers, point them at one endpoint through Quanta Inference (https://quantaintelligence.ai/inference), so a single key covers text, image, audio and embedding models and the failure contract has one place to live.

Worked example: numbers behind one fixture

Take one error class, PAYMENT_DECLINED. The fixture records a request, the envelope and the three expectations:

{
  "fixture": "payment_declined",
  "request": {"tool": "charge_card", "args": {"amount": 42, "currency": "USD"}},
  "response": {
    "error": {
      "code": "PAYMENT_DECLINED",
      "hint": "PAYMENT_DECLINED",
      "message": "Card issuer declined",
      "retryable": false
    }
  }
}

The test asserts that retryable is exactly false, that hint equals PAYMENT_DECLINED, and that no additional properties appear. Now suppose a refactor accidentally routes this code through the transient branch. The tool will retry a declined charge, and because a timeout after dispatch does not establish whether the side effect committed, repeated attempts can create duplicate effects unless the guaranteeing service deduplicates them retry safety and idempotency. A single boolean assertion catches it before it costs anything.

Assume the tool retries three times with a base delay of one second and exponential backoff, capped at three attempts agent error handling patterns. With the correct flag, one declined charge produces one dispatch and about zero seconds of waiting. With the wrong flag, you get three dispatches and roughly three seconds of backoff, plus cumulatively rising load on the payment provider. Multiply by the volume of declined cards in an hour and the cost of the bug is measured in duplicate attempts, not minutes.

For timeout classes, the numbers work the other way. Suppose a network blip produces one timeout. If the fixture wrongly forces retryable: false, one recoverable failure becomes one failed run plus a support ticket. If it correctly says retryable: true, the orchestration layer absorbs it silently and the model never learns a transport error occurred LLM tool calling error handling. The fixture is what keeps that routing decision from regressing.

Reading the diff when a fixture fails

A failing fixture should tell you which of four things happened: the tool genuinely changed, the fixture is stale, the schema is too loose, or the environment differs. Work through them in that order.

If the tool changed on purpose, update the fixture in the same pull request and say why in the commit message. If it changed by accident, you have caught real drift. If the schema is too loose, tighten the validator settings and re-run the negative fixtures. If the environment differs (a sandbox returns a different provider error code), split the fixture by environment rather than loosening the assertion.

One practical check: replay the fixtures against the last release as well as the current build. A failure in both means the fixture was stale before your change, so you do not chase a ghost. Retry policy itself should be service-aware and configurable rather than one hardcoded timeout applied to every call commercetools error handling guidance, and the fixture set should mirror that configuration so per-service differences are asserted, not assumed.

FAQ

FAQ

How many fixtures do I need?

One per error class per tool, plus one negative fixture per class to prove the schema rejects bad envelopes. If a tool has fewer than about a dozen classes, the full set runs in well under a second in-process.

Should fixtures be recorded from production?

Recorded responses are a good starting point for schema inference, but clean them up by hand: a single response rarely infers optional fields and formats correctly json-to-schema workflow. Never let an uncontrolled recording define expected behavior.

How is this different from ordinary API tests?

Ordinary API tests focus on the happy path and status codes. These fixtures assert the three failure-contract properties specifically: envelope shape, exact hint identity and the retryability boolean, each stored as data so a code change shows up as a fixture diff.

What about retries on non-idempotent tools?

Require an idempotency key generated by the orchestration layer, not the model, and have the service that owns the effect enforce deduplication idempotency and recovery. Then assert in the fixture that state-changing error classes are only marked retryable when a key is attached.

Can I reuse the same fixtures across tools?

The envelope schema can be shared when several tools return the same error shape, since consistency across endpoints is exactly what an error schema is for JSON Schema for error handling. Hints and retryability stay per tool, because those encode tool-specific behavior.

Sources

Sources

  1. P.E: 4.2, Why tool specifications matter in LLM APIs | by John Chiwai
  2. Non-Retryable Errors
  3. AIChatAgent terminalizes platform-transient response ...
  4. API Testing JSON Schema Validation Guide
  5. Error Handling and Recovery Patterns for AI Agents
  6. Patterns AI Agent Error Handling That Actually Works
  7. Should you retry 500 API errors?
  8. using JSON Schema for 3rd party API response validation
  9. How to Use Playwright to Validate an API Response Schema ...
  10. Resilient Builds With Can-I-Deploy - Pact Flow
  11. Agent Tool Call Retries: Idempotency and Recovery
  12. LLM Tool Calling Error Handling: Retries and Fallbacks
  13. Error handling through timeout and retries | API
  14. JSON Schema for API Testing: Error Handling and ...
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