AI agents

Simulating Refusals in Staging: Fixtures for Every Code

Antonio Evans7 min read

A top-down view of a test bench where a hand plugs labeled cassettes, one per reason code, into a harness board wired to a strip chart drawing backoff steps, with a red X routing one cassette to a tray marked NO RETRY.
Key points

Key points

  1. 1Generate one staging fixture per reason code from the same list object that the retry policy compiles from, so the two can never drift apart.
  2. 2Each fixture returns its code deterministically (same input, same code, same count) so a test can assert exact retry counts and backoff intervals without sleeping.
  3. 3Classify every code as retryable or terminal at the source, and make terminal codes fail the test the moment a retry is attempted.
  4. 4A build check fails when a code exists in the list without a fixture, which turns "we forgot to test that path" into a red build instead of a production incident.

Start from the list the policy actually reads

The fixture set is only as good as its source of truth. If your retry policy hardcodes a set of reason codes in a switch statement and your tests hardcode a different set in a fixture file, you have two lists that will disagree within a quarter. The fix is to define one machine-readable list (a JSON or YAML file, or an enum generated from it) and have both the policy and the fixture generator compile from it.

That single list should carry more than the code string. Each entry needs the classification (retryable, terminal, or retry-after-guidance), the escalation target, and a human description. This mirrors how typed contracts prevent drift between agents: if a payload violates the schema, it fails validation instead of silently corrupting downstream state, which is the argument made in MLflow's orchestration guidance. The same logic applies to error classification: an unclassified code is a contract gap, not a surprise.

A minimal entry looks like this:

{
  "code": "RATE_LIMITED",
  "retryable": true,
  "max_attempts": 5,
  "escalate_to": "queue",
  "description": "Short-term quota exhausted"
}

Keep this file in version control next to the policy. When someone adds a code, they add an entry, and the build check described later forces them to add a fixture in the same change.

Map each reason code to a deterministic response

A fixture is a small handler that inspects a control header or a seed and returns a fixed response for a given code. Determinism is the whole point: if the fixture occasionally returns a different code, your retry-count assertions become flaky and get muted, which defeats the exercise.

The practical pattern is a fixture registry keyed by code. The registry holds a function per code that returns the status, headers and body the real upstream would return. Two details matter:

  • Sequence state per test. Counters live in the test scope, not in the server process, so parallel tests do not interfere. Reset the counter at test start.
  • Retry-After honored. If a code carries a Retry-After header, the fixture should emit it, because the policy under test must read server guidance rather than only its own schedule. Webalert's analysis of retry storms notes that clients should honor server guidance such as Retry-After and cap attempts and elapsed time.

For codes that represent permanent failures, the fixture should never be configured to "succeed on the third attempt." That variant belongs to the transient codes. Mixing them is how terminal codes sneak into retry loops.

Here is a compact fixture registry in TypeScript:

type Fixture = { status: number; headers?: Record<string,string>; body: unknown };

export const fixtures: Record<string, (attempt: number) => Fixture> = {
  RATE_LIMITED: (n) => ({ status: 429, headers: { "Retry-After": "2" }, body: { code: "RATE_LIMITED" } }),
  UPSTREAM_UNAVAILABLE: (n) => n < 3
    ? { status: 503, body: { code: "UPSTREAM_UNAVAILABLE" } }
    : { status: 200, body: { ok: true } },
  INVALID_INPUT: () => ({ status: 422, body: { code: "INVALID_INPUT" } }),
};

The attempt argument lets a transient code recover after a fixed number of tries. That is what lets you assert "three attempts, then success" without any real clock.

Classify terminal codes and prove they are never retried

The most valuable fixtures are the ones that assert absence. Terminal codes must produce exactly one call and then escalate, because retrying them wastes time and can amplify load. Temporal's documentation on non-retryable errors makes the case directly: patterns that mark failures as permanent let the system fail fast instead of waiting through exponential backoff for an error that will never change, and it lists invalid input, missing records and authorization problems as the typical cases. Their exercise material on configuring retry policies with non-retryable error types shows both mechanisms: marking the error at the throw site and listing the type name in the policy.

Your fixture test for a terminal code is short and strict:

  1. Point the client at the fixture for INVALID_INPUT.
  2. Run the operation to completion.
  3. Assert the call counter equals 1.
  4. Assert the escalation sink received exactly one event with that code.
  5. Assert no sleep or timer was scheduled.

That last assertion catches a subtle bug: a policy that classifies the code as terminal but still calculates a backoff before checking the classification, which delays the escalation without any benefit.

A decision table for the whole list

Write the expected behavior down before you write the fixtures. The table below is the artifact that the fixtures encode; review it with whoever owns the escalation path. The "attempts" column is the number of calls the test should observe, not a promise about production timing.

Code classExample codeRetryableAttempts observedBackoff usedEscalation
Short quotaRATE_LIMITEDYesUp to max, honor Retry-AfterExponential with jitterQueue after exhaustion
Transient outageUPSTREAM_UNAVAILABLEYesUntil success or capExponential with jitterDead letter after cap
Permanent inputINVALID_INPUTNo1NoneImmediate to caller
Auth failureAUTH_INVALIDNo1NoneAlert security channel
Not foundRESOURCE_MISSINGNo1NoneReturn typed error
Ambiguous timeoutTIMEOUT_UNKNOWNYes, only if idempotentUp to maxExponential with jitterDead letter after cap

The last row is the one people get wrong. A timeout does not tell you whether the operation happened, so retrying is only safe when the request carries an idempotency key. PostGrid's retry guidance recommends idempotency keys for create requests so that a retried call returns the same response rather than creating a duplicate, and it shows a fixed schedule with jitter and a small set of retryable statuses. Fixtures should include a duplicate-creation test: fire the fixture twice with the same key and assert the result is one record.

The general shape of the backoff the fixture exercises is the familiar formula, delay = initialDelay * factor ^ retryNumber, with a cap and jitter applied on top, as described in PostGrid's documentation and in AWS's write-up on timeouts, retries and backoff with jitter, which frames the three tools as timeouts, bounded retries and backoff to avoid converting a small failure rate into a full outage.

Wire fixtures into the retry policy with a fake clock

Timing is where retry tests become slow and flaky. The standard remedy is to inject a clock abstraction and a scheduler, then let the fixture advance time instantly. Webalert and the AWS builder post both stress bounding retries by attempt count and elapsed deadline, which you can only assert cheaply if time is virtual.

A worked example with numbers makes the assertion concrete. Suppose the policy starts at 1 second, doubles, caps at 30 seconds, and allows 5 attempts. With full jitter, each observed delay is uniform between zero and the capped value, so a strict equality check on the delay will fail. Assert bounds instead:

AttemptUncapped delayCap appliedAssertion on observed delay
11 s1 s0 to 1 s
22 s2 s0 to 2 s
34 s4 s0 to 4 s
48 s8 s0 to 8 s
516 s16 s0 to 16 s

If you use equal jitter instead, the lower bound becomes half the capped value. Either way, the fixture test asserts three things: the number of calls, the interval bounds, and that the total elapsed time never exceeds the caller's deadline. A test that asserts only the call count will pass happily while a policy waits an hour.

For the rate-limited fixture, one more assertion matters: the policy must use the Retry-After value when the computed delay is shorter than it. That is a decision rule worth encoding: the effective wait is the maximum of the computed backoff and the server guidance, so honoring the server never makes you faster than the server asked.

Fail the build when a code has no fixture

The last piece is the guardrail. A test that iterates the code list and checks for a registered fixture converts an omission into a build failure. It is a few lines:

import { codes } from "../policy/reason-codes";
import { fixtures } from "./fixtures";

test("every reason code has a fixture", () => {
  const missing = codes.map(c => c.code).filter(code => !(code in fixtures));
  expect(missing).toEqual([]);
});

Add a second guard for classification: every code must declare retryable, and every retryable code must declare max_attempts and an escalate_to. A code with no escalation target will retry until the cap and then vanish, which is exactly the silent failure that MLflow's checklist warns about when it recommends an explicit task state object and replay logging rather than black-box behavior during failures.

One more guard closes the reverse gap: assert that every fixture key exists in the code list. That catches a fixture left behind after a code was renamed, which otherwise gives false confidence that a path is covered.

The insight: cost scales with replay, not with retries

The reason fixtures deserve exact assertions rather than a smoke test is that retries in modern systems replay more than a single call. In tool-using and agent-style services, a retry often resends the accumulated context, so the cost of the Nth attempt is the cost of the whole history up to that point. An analysis of retry budgets for LLM agents puts numbers on it: a 20% per-step retry rate over three steps yields roughly 1.7 to 1.9 times baseline tokens once context replay is counted, and 2.2 to 2.5 times over five steps, with retries stacked at multiple layers multiplying into much larger fan-out. A single session described there consumed a large token volume because usage-limit errors did not stop the loop.

The decision rule that follows is simple and testable: after exhausting attempts, the policy must stop and escalate rather than reset the counter. Write a fixture that returns the transient code forever, run the operation to completion, and assert both the call count and that no later call occurs. If your test harness cannot express "never called again," add that capability before adding more fixtures; it is the assertion that prevents a retry storm in staging from becoming one in production.

The same replay logic explains why fixtures should be cheap to add and cheap to run. One fixture per code, executed with a virtual clock, keeps the whole suite in seconds and keeps the classification honest as the list grows. When the surface is large (many codes across several upstream providers), a single gateway in front of the models and services under test simplifies the harness: Quanta Inference exposes text, image, video, audio and embedding models through one OpenAI-compatible API key with prepaid US-dollar pricing, which means one fixture layer and one set of reason codes instead of one per provider.

FAQ

FAQ

Why not just test retries against a real failing dependency?

Real dependencies fail nondeterministically, which makes retry-count assertions flaky, and flaky assertions get disabled. Fixtures give you the same code, the same count and the same Retry-After on every run, so a failure means a policy bug rather than weather. Keep a small number of contract tests against the real dependency to catch drift in status codes, but drive the retry matrix from fixtures.

How do I test backoff schedules without making the suite slow?

Inject a clock and a scheduler, advance time in the test, and assert bounds rather than exact delays when jitter is applied. With full jitter, the observed delay is uniform between zero and the capped value, so assert a range. Test both the attempt count and total elapsed time against the caller's deadline.

What if a code is retryable only for idempotent operations?

Make idempotency part of the fixture, not part of the code classification. The fixture test should include a case with and without an idempotency key: with the key, a retry is expected; without it, the policy should escalate immediately. This is the same reasoning behind idempotency keys in PostGrid's retry guidance.

Where should the reason code list live?

In one version-controlled file or generated enum that both the policy and the fixture registry import. Duplicating the list in tests is the most common way fixtures drift from policy. Add a build check that fails when a code has no fixture and when a fixture has no code.

How many fixtures is too many?

One per code is the floor, not a target to minimize. The maintenance cost sits in the classification table, not in the fixture bodies, because most fixtures are a few lines returning a status and a body. Automate the boilerplate from the list so adding a code produces a stub fixture that fails until it is filled in.

Sources

Sources

  1. One post tagged with "agent orchestration patterns"
  2. Exponential Backoff & Jitter: Stop Retry Storms (Code & Math)
  3. Non-Retryable Errors
  4. edu-errstrat-go-code/exercises/non-retryable-error-types ...
  5. Response Codes and Retry Logic - Getting Started - ReadMe
  6. Timeouts, retries, and backoff with jitter
  7. Retry Budgets for LLM Agents: Why 20% Per-Step Failure ...
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