AI coding

Schema Changes That Break Retry Logic: A Compatibility Suite

Antonio Evans10 min read

A split-screen illustration: on the left, an agent retry loop shown as a flowchart with a circular arrow labeled 'retry'; on the right, a schema diagram of an error object with fields like 'code', 'message', and 'hint'.
Key points

Key points

  1. 1Once an agent branches on an error code or reads a hint field, that part of the error payload is a public interface, and changing it is a breaking change even if no schema file changed.
  2. 2New fields must be optional and must carry defaults that reproduce the old retry decision, so an agent that ignores unknown fields still behaves as before.
  3. 3Error codes are a closed vocabulary: adding a code is safe, removing one, renaming one, or reusing one for a different cause is not.
  4. 4A compatibility suite that replays a fixed set of error fixtures against both the previous and current version will catch hint drift that ordinary integration tests miss.

Why an error payload becomes a contract

Most teams treat error responses as diagnostics. An agent does not. It reads the error, classifies it, and picks the next action: retry, fix the request, escalate, or abandon. The error body is therefore the control-flow input for the retry loop, and every field the agent touches becomes an interface it depends on.

The general principle is well established in API design: an error should carry an application-specific code and a message so that software can decide what to do next without parsing prose the Speakeasy error guide. The same guide stresses that errors should help computers act, which is exactly why the shape of those fields matters. MCP tool contracts make the point explicitly: a tool is a contract that includes its error behavior and result structure, and a change to either can break an agent even when the server code still compiles contract tests for MCP tools.

The AdCP error specification shows what a mature version looks like. Level 1 is code plus message so agents can match codes to classify failures. Level 2 adds recovery, plus optional retry_after, field, and suggestion, so agents can auto-retry transient errors and self-correct correctable ones AdCP error handling. The moment you ship Level 2, those hint fields are load-bearing. An agent that reads recovery to decide whether to retry has a dependency on a field that did not exist in the previous version, and the compatibility question flips direction: it is now the older agents that need protection.

There is a second, quieter reason the contract matters. A schema mismatch inside a tool call surfaces as an exception, and most retry libraries default to retrying on any exception, because the alternative is enumerating every recoverable class forever. A parse failure is not transient, but the retry layer cannot tell it apart from a network blip, so it spends its attempts proving the same thing the silent contract drift write-up. The visible symptom is a retry budget that stays green while the success rate on that path sits at zero.

The four invariants of a retry-compatible error schema

Write these down before you write a single assertion. Everything in the suite is a mechanical check of one of them.

InvariantRuleWhat breaks if violated
Additive onlyNew fields are optional; no field is removed or renamedOlder agents raise KeyError or read null
Stable code meaningA code keeps its cause and its implied action foreverAgents retry something permanent or give up on something transient
Defaulted hintsMissing hint fields resolve to the old retry behaviorAgents that predate hints retry differently after upgrade
Ordered fallbackUnknown codes and unknown hint values fall back to a defined defaultA new code silently becomes "retry forever" or "abandon"

The fallback row is the one teams forget. When you introduce recovery, every code in the old enum needs a defined recovery value, and the default for unrecognized input needs to be the safe one. Look at the two common defaults: retrying an unknown code burns tokens on a permanent failure, while abandoning an unknown code drops a transient one. Pick the default deliberately and test it.

Backward compatibility testing exists precisely to answer whether an updated version keeps working for consumers built against earlier versions, and the catalogue of breaking patterns includes renaming a response field, changing a field type, and changing a field's meaning without renaming it backward compatibility testing for APIs. All three appear in error payloads, and all three are easy to ship by accident because error code paths get less review than success paths.

Assertion suite 1: shape and versioning

Start with the envelope. Every error must carry a stable top-level shape before you can assert anything about hints. Capture the previous version's serialized error for a handful of representative failures and store them as fixtures, the way golden files pin CLI output in end-to-end tests writing integration tests for a Go CLI.

Assert presence and type, not the entire serialized object. A test that compares a full error JSON to a stored snapshot will fail on harmless serialization changes and tempt someone to regenerate the fixture without reading the diff. Contract testing guidance is blunt about this: make assertions only about things that would affect the consumer if they changed, and use loose matching field by field Pact consumer tests.

def assert_error_shape_v1_compatible(err: dict) -> None:
    # Fields that existed at v1 must still exist with the same type.
    assert isinstance(err["code"], str)
    assert isinstance(err["message"], str)
    assert isinstance(err["status"], int)
    # New fields are allowed, but must be optional and nullable-safe.
    for optional in ("recovery", "retry_after", "field", "suggestion"):
        if optional in err:
            assert err[optional] is not None or optional == "retry_after"

The retry_after special case is worth spelling out. If the field is present with a null value, the agent has to decide what null means, so either forbid null and omit the field, or define null as "use default backoff" and assert it.

Assertion suite 2: the code enumeration

Error codes are the branch points of the retry loop, so treat the set of codes as an enumerated type with a compatibility rule. The Linux exit-code conventions are a useful mental model here: a small set of codes carries reserved meaning, and the documentation advises against reusing them for user-defined purposes because it makes troubleshooting ambiguous exit codes with special meanings. The same discipline applies to your application codes: reserved meanings, no reuse, documented.

Practice from CLI design aimed at agents is even more directly applicable. One practitioner's contract lists exit codes with fixed meanings, including a distinct code for conflict so a caller can skip creation rather than parse stderr CLI tools that agents want to use. The parallel for a JSON error payload is a stable map from code to action.

Generate the code inventory from the previous release tag and diff it:

# codes-v1.json and codes-v2.json are generated from the error registry
jq -r '.codes[] | "\(.code) \(.cause) \(.defaultAction)"' codes-v1.json | sort > v1.txt
jq -r '.codes[] | "\(.code) \(.cause) \(.defaultAction)"' codes-v2.json | sort > v2.txt
comm -23 v1.txt v2.txt   # must be empty: nothing removed or redefined
comm -13 v1.txt v2.txt   # additions: fine, but each needs a default action

Any line in the first diff is a release blocker. A code that vanished will land in an agent's unknown-code branch, and you had better have tested that branch. A code whose cause or default action changed is arguably worse, because the field-name diff is clean and only the meaning moved.

ChangeVerdictRequired test
Add a new code with a new default actionCompatibleUnknown-code fallback returns the safe default
Remove a codeBreakingNone can pass; must be a major version
Rename a codeBreakingNone can pass; alias the old name for one major cycle
Change a code's default actionBreakingFixture for the old action fails loudly
Split one code into twoBreaking for retriesBoth new codes must preserve the old action, or bump

That last row deserves a note. Splitting TRANSIENT into RATE_LIMITED and UPSTREAM_TIMEOUT is a good design improvement, and it is still a breaking change for any agent that matched on TRANSIENT. The compatible path is to keep TRANSIENT with its old action and add the specific codes alongside it, then retire the broad code only at a major version.

Assertion suite 3: hint presence and defaults

This is the suite that catches the subtle break. Every hint field has three states: absent, present with a value, and present but unrecognized. Each needs an asserted outcome, and the outcome must match what the old version did.

Take recovery as the worked example. Suppose v1 had no hint and the agent's policy was "retry any error carrying a 5xx status, up to three attempts." v2 adds recovery with values like retry, correctable, and permanent. The compatibility requirement is that an error carrying the old shape (5xx, no recovery) produces the same retry decision in v2 as it did in v1.

Fixturev2 field stateExpected retry decisionWhy
Timeout errorrecovery: "retry"Retry with backoffExplicit hint agrees with old 5xx policy
Timeout error, halted producerno recoveryRetry with backoffOld policy must survive
Validation errorrecovery: "correctable"Do not retry; return field to callerCorrectable means change the request
Auth errorrecovery: "permanent"Do not retry; escalatePermanent means stop
Any errorrecovery: "quarantined"Apply default, log unknown valueForward compatibility

Write the assertions against the agent's decision, not against the field. The decision is the thing the consumer depends on, and asserting it keeps the test honest when you add a fourth value to the hint vocabulary.

@pytest.mark.parametrize("fixture,expected", [
    ("fixtures/timeout_v1.json", "retry"),
    ("fixtures/timeout_v2_retry.json", "retry"),
    ("fixtures/validation_v2_correctable.json", "fix_request"),
    ("fixtures/auth_v2_permanent.json", "escalate"),
    ("fixtures/unknown_recovery_v2.json", "default"),
])
def test_retry_decision_unchanged(fixture, expected):
    err = json.loads(pathlib.Path(fixture).read_text())
    assert decide(err) == expected

One more rule for hints: a hint must never contradict a hard signal without an explainable reason. If recovery says retry but retry_after is absent and the status is 400, the agent has two conflicting inputs. Define precedence once (hint wins over status for the action, status wins for the category) and assert it, because "which wins" is the question every agent author will ask.

Assertion suite 4: fallback behavior across versions

The fallback path is where new codes and new hint values land, and it is the least tested because it requires deliberately malformed input. Build three fixtures per version: an error shaped like the previous version, an error with an unknown code, and an error with an unknown hint value. Assert that the first behaves identically to the previous release and the other two both land on the defined safe default.

The case for this is strongest in multi-agent systems, where failures cluster at the handoff and the fix is validation at every edge between agents error handling and retry logic between agents. An unknown error code is a handoff where one side's vocabulary ran out. A schema-level validator at the edge turns that into an immediate, visible failure instead of a corruption that cascades three steps downstream.

Record-and-replay tooling gives you the cheap version of this suite. Capture real error traffic from staging, replay it against the new version, and diff the decisions. Tools in this space describe the approach as capturing actual requests and responses and replaying them as deterministic tests, with statistical detection of fields that vary run to run, such as timestamps and identifiers, excluded from strict comparison record and replay testing. Point that at your error fixtures and you get the "did any retry decision change" diff for free.

A worked number makes the payoff concrete. Suppose an agent makes 10,000 tool calls a day on one path and 2 percent hit a transient error, so 200 retries a day. A schema change that makes those retries fail deterministically replaces 200 recoverable failures a day with 200 user-visible degradations, and the retry budget shows at most 600 attempts (three each), still under a budget sized for a heavy day. Nothing pages. The only metric that moves is "retries that recovered," which most dashboards do not chart. Adding that metric and the fixture suite together turns a two-week silent break into a red build.

Putting the suite in CI

Run the compatibility suite as a required check on every pull request that touches the error serializer, the code registry, or the hint vocabulary. Keep three collections of fixtures: v1 errors produced by the previous release, v2 errors produced by the current branch, and synthetic errors that are deliberately wrong (unknown code, unknown hint, missing optional field, wrong type in an optional field). The first two prove the contract holds; the third proves the fallback holds.

Structure the tests the way contract tests are structured: unit tests for the decision function, contract tests for the serialized shape, and integration tests for the round trip through the real transport integration tests in ASP.NET Core. Each layer answers a different question, and the contract layer is the one that catches hint drift because it asserts against a fixed, versioned expectation rather than whatever the current code produces.

Two operational habits make the suite durable. First, a named owner for the error contract, meaning a person whose job includes watching the code registry and hint vocabulary for changes, because an agent does not receive the deprecation email the tool schema migration write-up. Second, a rule that regenerating a fixture requires a written sentence in the pull request explaining which consumer behavior changed and why. The second rule is what prevents a green build from meaning nothing.

Recording the fixtures is mostly a matter of capturing real traffic. Most integration suites already run against a test host, so the capture step is one middleware that writes the error body to a file when a flag is set integration tests in ASP.NET Core. Keep the capture limited to errors, because that is the surface the suite asserts on.

If you run agents across several model providers, the same fixtures are worth running through each provider's tool-calling path, since a hint that parses correctly in one tool schema may arrive wrapped differently in another. Quanta Inference puts text, image, and embedding models behind one OpenAI-compatible API key, which makes the multi-provider fixture run a config change rather than a rewrite, and the Quanta Inference page documents the endpoint.

When to break compatibility on purpose

Sometimes the right move is a deliberate break. A hint vocabulary that turned out to be wrong, a code whose meaning drifted internally, or a retry default that caused an incident: those justify a major version. The rule is not "never break," it is "break loudly and only on purpose." A deliberate break means a new version field or endpoint, a migration note that names the affected codes and hints, and a compatibility suite entry that asserts the old behavior is gone, so the change cannot be mistaken for an accident.

For agent-building teams, the payoff of the suite compounds with scale. Every agent, every skill installed into a coding agent, and every workflow that consumes your errors is a consumer you cannot see. The compatibility suite is the only way to enumerate them, because it encodes the behaviors instead of the callers. The assertions you write about shape, codes, hints, and fallback are the same assertions every downstream agent team would write if they had the time. Writing them once, at the boundary, is the cheapest place to put them.

FAQ

FAQ

What qualifies as a breaking change in an error payload?

Removing or renaming a field, changing a field's type, removing or renaming a code, reusing a code for a different cause, and changing a hint field's default behavior all count. Adding an optional field with a safe default does not. The test is whether an existing agent's retry decision changes without its code changing.

Should new hint fields be required or optional?

Optional, always. A required field breaks every producer that has not been upgraded and every agent that constructs an error for testing. Optional fields with a defined default let producers adopt hints incrementally while consumers that ignore the field keep their old behavior, which is the additive-only rule.

How do I test an agent's behavior against an error version it was never built for?

Store fixtures from the previous release and assert the agent's decision function against them, then add synthetic fixtures with unknown codes and unknown hint values and assert both land on the documented safe default. This is the same forward-compatibility check that record-and-replay tooling provides when you replay captured traffic against a new version.

What metrics reveal a silent retry break?

Chart "retries that recovered" rather than "retries used," because a retry that fails deterministically still counts against the budget the same way three successful retries would. Pair it with per-path success rate so a path that is retrying within budget but never succeeding shows up as a zero rather than an unexplained dip.

Does a schema registry solve this?

Partly. A registry can enforce that new fields are optional and that codes are not removed, which covers the shape and enumeration suites. It cannot assert that the agent's retry decision is unchanged, because that behavior lives in the consumer. Keep the decision-level fixtures alongside any registry checks.

Sources

Sources

  1. Errors Best Practices in REST API Design - Speakeasy
  2. Testing MCP Tool Contracts with Contract-Based Integration Tests
  3. Error Handling - AdCP
  4. The Tool Schema Migration That Broke Your Agent's ...
  5. Backward Compatibility Testing for APIs Explained
  6. Writing integration tests for a Go CLI application
  7. Writing Consumer tests
  8. Appendix E. Exit Codes With Special Meanings
  9. Writing CLI Tools That AI Agents Actually Want to Use
  10. Error Handling and Retry Logic Between Agents
  11. Record and Replay Testing Guide
  12. Integration tests in ASP.NET Core
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