Security
Key points

Key points

  1. 1Error codes and the HTTP statuses that carry them are part of the API contract, so a vendor change to either can break retry, fallback and idempotency logic without any deploy failing.
  2. 2Write a test for every failure scenario your client repairs, pinning the exact status, the error code and the machine readable fields your code reads.
  3. 3Run those checks against the vendor's sandbox on a schedule and diff the captured error responses release over release.
  4. 4A changed code should fail your build before a customer sees a duplicate charge or a silent reconciliation mismatch.

Why error responses drift out of the contract

Most contract tests were written to protect the happy path. Teams assert that a 200 body still has id, created_at and amount, because that is the payload the UI renders and the payload that breaks visibly when it changes. Error responses get a fraction of that attention, even though client code often branches on them more aggressively than on success bodies.

The gap is real across the industry. The SmartRecruiters API documentation lists "Status Code & Error Changes" as a category of breaking change: altering the HTTP status for an existing scenario, changing the structure of error responses, changing error codes and their meanings, or removing codes clients might rely on according to their breaking changes policy. The same page classifies additive changes (new optional fields, new enum values) as non-breaking, which is why an error payload can grow a new member with no warning while an existing member quietly changes meaning.

Contract testing tools can only judge what they are given. The Swagger contract testing documentation is explicit that "Contract Testing can only decide based on the information it receives. If the consumer contract does not contain all the interactions the consumer uses, then the resulting checks may say it is safe to deploy when it could not be" in the compatibility checks guide. If your consumer contract records only success interactions, the vendor's compatibility checker will happily pass a release that renamed card_declined to payment_declined.

There is a second layer of risk that schema testing does not reach at all. A schema test asserts that one system is compatible with a schema at a point in time, while a contract test asserts that two systems can actually communicate on agreed interactions as described in Pactflow's comparison. Error semantics are exactly the kind of thing that a valid schema will not catch: a 409 that becomes a 500 with the same documented envelope still validates against the schema, and still breaks your code.

What actually breaks when a code changes

Walk through four client behaviors and see what each one depends on.

Retry. Retry policies are written as predicates over the error. In the TanStack Query community thread on testing retry logic, the example retry function returns false for error.code === 403 and otherwise retries up to three failures discussed in the TanStack Query repository. The maintainer's advice in that thread is to extract the function and test it on its own, because the branch on the code is the part that matters. Now imagine the vendor starts returning 401 where it used to return 403. Your predicate no longer matches, and the client retries an authentication failure three times against a rate limited endpoint.

Classification of retriable and non-retriable failures. The retry practices described for LLM systems depend on separating transient errors from permanent ones before backoff starts covered in this retry logic deep dive. Rate limiting is the canonical transient error: a client that receives HTTP 429 should back off and try again, not give up as the LLM Engine documentation states. If a vendor moves its throttle signal from 429 to a 400 with a body code of rate_limited, every client that keys off the status code treats a temporary condition as permanent and drops the request.

Idempotency. The classic failure is the one you cannot see in logs: the first request succeeds at the vendor but times out on your side, your client retries with the same idempotency key, the vendor returns a conflict code the old code was duplicate_request, and your repair logic sends a fresh key. The user is charged twice. Nothing failed at deploy time. The keyword in your repair rule simply stopped matching.

User messaging. A checkout page maps error codes to human text: card_declined becomes "Your card was declined, try another card", insufficient_funds becomes "Add funds and try again". When the vendor consolidates these into a generic payment_failed, the mapping falls through to a default, and customers get an unhelpful message for a recoverable problem.

The same class of drift is listed as a risk in public API governance discussions: an API can stay technically functional while diverging from the contract consumers depend on, with a changed HTTP status code or response field cited among the examples raised in the GitHub community discussion on API contract drift.

A checklist of what to pin per failure scenario

For each failure your client repairs, capture and assert the full tuple. Use this table as a template when you write the fixtures.

What to assertWhy it mattersExample failure if it changes
HTTP status codeDrives generic middleware, gateways and status-based retry predicatesConflict moves from 409 to 400 and retry middleware treats it as a validation error
Machine readable error codeDrives fine-grained branches in your repair logiccard_declined becomes payment_failed, which is not retried
Error envelope shapeDetermines whether your parser can read the code at allCode moves from error.code to errors[0].type
Whether the code is retriable, as documentedSets the retry/abort decisionRate limit signaled with a non-retriable code
Any identifier in the responseDeduplication and idempotency keysConflict response stops returning the original request id
Human message field presenceFallback UI textMessage field removed, blank banner shown

Two practical rules follow from the table. First, assert the exact code string, not a fuzzy match, because the whole point is to be notified when it changes. Second, keep an explicit list of codes your client treats as retriable, and fail on any code you have not classified, so an unknown error goes to a human instead of a guess.

Capturing error responses as fixtures

HTTP fixtures give you a language neutral record of a real response. DNSimple built their client test suites across Ruby, Go, Elixir and Node by recording raw responses from curl output, headers included, and replaying them through stubbing libraries in each language as described in their API client testing writeup. The point of storing the whole response is that the status line and the headers are part of what you are testing, not just the JSON body.

The same idea applies to errors. A minimal capture loop that stores each known failure response, status line included, looks like this:

# capture one fixture per failure scenario, status line and headers included
for case in declined insufficient_funds duplicate_request rate_limited; do
  curl -sS -i -X POST "$VENDOR_BASE/charges" \
    -H "Authorization: Bearer $VENDOR_SANDBOX_KEY" \
    -H "Idempotency-Key: fixture-$case" \
    -d "@fixtures/requests/$case.json" \
    > "fixtures/errors/$case.http"
done

Then run a diff in CI or on a schedule:

# fail the job if the sandbox error surface changed
diff -ru fixtures/errors.expected fixtures/errors || {
  echo "Vendor error responses changed. Review before merging."
  exit 1
}

For test isolation inside your own suite, mocking the vendor keeps runs deterministic. Laravel's Http::fake with recorded responses is the documented approach, and the HttpFixtures package adds generated realistic data so real sensitive values never land in the repo per the package documentation. Framework level interception works the same way: TestCafe request hooks can log outgoing calls and inspect status codes and bodies, or serve a mock response in their place per the TestCafe interception guide. Playwright tests can retry a flaky fetch and fall back to mock data when every attempt fails per this retry and mocking walkthrough.

Two cautions on fixtures. Do not sanitize out the status code or the code string while removing PII, because those are the fields under test. And record the vendor's API version header in the fixture, because that is your anchor when a diff appears.

Simulating the error sequences your repair logic assumes

A snapshot of a single error tells you what the vendor returns once. Your repair logic cares about the sequence. The canonical retry scenario is request one returns 400, request two returns 400, request three returns 200, and the application should have retried exactly twice as described in Beeceptor's retry simulation guide. The same guide lists other sequences worth modeling: a payment gateway that fails twice before accepting a transaction, a job API returning PROCESSING several times before COMPLETED, a webhook endpoint that fails its first deliveries, and circuit breaker and fallback paths.

State based mocking makes those sequences deterministic, because a counter advances per request and resets after success. That mirrors how you should test your own repair code: assert the number of attempts, the delay between them and the final outcome, not just the first response. The TanStack Query contributor example does this by mocking a persistent 429 and waiting for failureCount to reach three before asserting the query enters its error state in the same discussion thread.

Rate limit behavior deserves its own sequence test. Exponential backoff with random jitter is the standard mitigation because it prevents retries from a fleet of clients from synchronizing into a thundering herd per the LLM Engine rate limit guide. Rate limiting exists to prevent server overload and ensure fair usage across clients per Orq's rate limit overview. If the vendor changes the code that signals a throttle, your backoff never engages and you hammer the endpoint until something worse happens.

Making drift visible before a customer finds it

Snapshotting in CI catches changes when you upgrade, but vendors ship continuously. You need a separate cadence.

Run a scheduled job, daily or on every weekday morning, against the vendor's sandbox. It executes the failure scenarios, captures the responses and compares them to the committed baseline. When the diff is non-empty, it opens a ticket or a pull request with the changed fixtures attached. This is the scheduled-diff pattern in practice: the vendor's sandbox becomes an early warning system, and your repository holds the expected behavior.

Two things make this work over time. Pin the vendor API version explicitly in the request, because the SmartRecruiters guidance recommends pinning integrations to a specific API version rather than "latest" to avoid unexpected issues, and it recommends reviewing the changelog for that version per their breaking changes documentation. Second, treat the diff like a code review: compare old and new code strings, decide whether the change is additive or semantic, and update your classification list in the same pull request.

For broader defensive design, apply the same least privilege thinking to credentials used by the job. Keep sandbox keys scoped to the minimum the failure scenarios need, and rotate them; the same discipline of scoped credentials and short-lived tokens limits blast radius when a dependency is compromised per Sweet Security's AI agent supply chain guidance. If agents or automation call the vendor, inventory that integration alongside models and tools so a change can be traced to every affected consumer per their AI bill of materials guidance.

If you want that scheduled harness to run against the specific model APIs your product depends on, a single endpoint layer across providers makes the diff matrix smaller, and Quanta Inference (https://quantaintelligence.ai/inference) is one way to get there.

A worked example with numbers

Take a payments integration with three failure scenarios your client repairs: declined card, insufficient funds, and duplicate request. Your repair logic is: no retry on declined, prompt the customer on insufficient funds, return the original request id on duplicate.

Suppose these three scenarios account for 400 failed checkouts a day in your traffic. If the vendor renames the duplicate-request code and your matcher stops firing, the fallback path sends a new idempotency key on every retry of a lost response. Assume a lost-response rate of 1 in 200 requests and 2,000 payment attempts a day. That is roughly 10 lost responses daily, of which some fraction are duplicates. If half of those 10 hit the renamed code and get charged twice, that is 5 double charges a day, 150 a month, each one a refund, a support ticket and a trust hit. A scheduled fixture diff that fails on the renamed code costs one engineer an afternoon and catches the change the morning after the vendor's release.

The arithmetic matters because it changes the decision rule. Error contract tests are not worth it for every endpoint. They are worth it wherever the cost of a wrong repair is money, duplicate side effects, or a lost order. Rank your failure scenarios by that cost, write assertions for the top handful, and leave the rest to generic handling with alerting on unknown codes.

Decision rule and where to start

Use this rule when you decide what to pin: if a failure scenario has repair logic, has money or data side effects, or appears in a user facing message, it gets a fixture and an assertion on status, code and the fields your code reads. If a failure is logged and abandoned, it gets alerting on unknown codes instead.

A sensible first week:

  1. List every branch in your code that reads an error code or a status.
  2. Rank them by cost of misfiring, and keep the top ones.
  3. Capture real sandbox responses for each, status line included.
  4. Write assertions that fail on any change to status, code or consumed fields.
  5. Schedule the capture job daily and route diffs to a human.
  6. Add an unknown code path that alerts instead of guessing.

When several teams depend on the same vendor, the fixture baseline belongs in one shared place. If that is the shape of your problem and you need a hand mapping which integrations to cover first, a free 30-minute AI discovery call (https://quantaintelligence.ai/calendar) will get you a tailored coverage recommendation.

FAQ

FAQ

Isn't this just contract testing?

It is contract testing applied to the half of the contract most teams skip. Schema and consumer driven tests will pass a release that keeps every success shape and silently changes error codes, because the tooling only checks the interactions you recorded per the Swagger compatibility checks documentation.

Which error fields should I assert exactly?

Assert the HTTP status, the machine readable code string, the path the code lives at in the envelope, and any identifier your repair logic reads (the original request id for idempotency, for example). Assert exact strings, not patterns, because the purpose is to be notified on change.

How often should the scheduled check run?

Daily against the sandbox is a reasonable default, and additionally on every vendor version upgrade you accept. The cost of the check is low and the value is the diff arriving before customer traffic does.

How do I test retry behavior without slow sleeps?

Extract the retry predicate and the backoff calculation into pure functions and unit test them with fake timers, then run one integration level sequence test with a mocked backend. This is the maintainer-recommended approach in the TanStack Query thread on retry testing discussed here.

What if the vendor changes the code but updates their docs too?

Treat it the same. A documented change still breaks your deployed client until you ship a fix, and your scheduled diff gets you the notice earlier than a changelog you might not read. Update the baseline and the classification list in the same pull request that adapts the client.

Sources

Sources

  1. Breaking Changes
  2. Compatibility Checks (breaking change detection)
  3. Contract Testing vs. Schema Testing | Pactflow
  4. Testing Retry Function Logic #2926 - TanStack query
  5. Mastering Retry Logic Agents: A Deep Dive into 2025 Best ...
  6. Rate limits
  7. How do you detect API contract drift before it breaks ...
  8. API Client Testing with HTTP Fixtures
  9. Gromatics/httpfixtures: A Laravel package that generates ...
  10. Intercept HTTP Requests | Intermediate Guides
  11. Handling Unreliable API Endpoints with Custom Retry ...
  12. API Retry Logic
  13. API Rate Limits Explained: Best Practices for 2026
  14. AI Agent Supply Chain Security Risks and Best Practices
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