Fault injection testing: prove timeouts, retries, and circuit breakers fire
PLFault injection testing, also called error injection, deliberately breaks a dependency (adds latency, resets connections, returns 500s, kills pods) to prove that timeouts, retries, and circuit breakers fire before production meets the same failure. Coding agents write these experiments quickly. The proof comes from a human-approved fault matrix and from tests that go red when the protection is removed.
Your order service passes every test, then hangs for 40 seconds the first time PostgreSQL slows down under load. The connection pool fills, requests queue, and the timeout everyone assumed existed turns out to be a server-side setting that network latency never trips. An agent wrote the retry wrapper last sprint, and nobody read it closely, because nobody reads every line anymore.
This page is for developers who own a service’s resilience code and tech leads who sign off on staging chaos experiments. It assumes you already have integration tests against a real database; fault injection stresses the same seams.
What you get from agent-built fault injection tests
Section titled “What you get from agent-built fault injection tests”- A fault matrix format that turns “the service must be resilient” into rows an agent can implement and a reviewer can approve in two minutes
- A working Toxiproxy suite for a Node.js and PostgreSQL service, including the control test that proves the proxy is really in the path
- A circuit-breaker test for
opossumthat counts upstream calls instead of trustingnock.isDone() - A bounded Chaos Mesh workflow for staging, with server-side validation and an abort command
- Four copy-paste prompts, a CI workflow with least-privilege permissions, and recovery steps for the ways chaos experiments go wrong
Where fault injection fits among your tests
Section titled “Where fault injection fits among your tests”Fault injection runs at two layers. Start at the application layer, because it is deterministic and cheap enough for every pull request, and move to the system layer only after each service fails fast on its own.
| Layer | Tools | Runs where | Proves | Cadence |
|---|---|---|---|---|
| Application | Toxiproxy (TCP proxy), nock or MSW (HTTP mocks) | Laptop and CI | One service’s timeouts, retries, fallbacks, and pool hygiene | Every pull request |
| System | Chaos Mesh, LitmusChaos (Kubernetes CRDs) | Staging cluster | Recovery, blast radius, and steady-state SLOs across services | On demand or scheduled, behind approval |
Load behavior is a different question. If you need to know how many requests per second the service handles before it degrades, see load, stress, and benchmark testing first; chaos against an idle staging cluster proves little.
Write the fault matrix before the agent writes code
Section titled “Write the fault matrix before the agent writes code”Agents are good at producing resilience tests and bad at choosing the numbers. Ask one to “add timeout tests” and it invents a 5-second bound that matches nothing in your config, then writes code to satisfy it. So the human decision comes first, as a table in docs/resilience/order-service.md:
| Dependency | Fault | Expected behavior | Bound |
|---|---|---|---|
| PostgreSQL | 4 s latency on every response | createOrder rejects with the client-side query timeout; no pooled client leaks | Fails within 2.5 s |
| PostgreSQL | Connection reset | One retry with backoff, then a 503 | Two attempts in total |
| Inventory API | Error rate above 50% (opossum compares strictly) over at least volumeThreshold calls (here 5) | Breaker opens; next call serves the cached fallback with no network call | Zero upstream calls while open |
| Inventory API | 3 s response time | Counts as a failure (breaker timeout 1 s) | Fails within 1.2 s |
Each row is an acceptance criterion in Given/When/Then shape, the same contract described in executable acceptance criteria. You review and approve this table instead of the test code. The agent’s job is to make each row a test that fails today if the protection is missing.
Inject dependency faults locally with Toxiproxy
Section titled “Inject dependency faults locally with Toxiproxy”Toxiproxy is a TCP proxy with an HTTP admin API on port 8474. Your app connects to the proxy instead of the database, and each test adds a “toxic” (latency, timeout, bandwidth, reset_peer, slicer, limit_data, slow_close) for its duration.
-
Start Toxiproxy next to your local PostgreSQL and create a proxy that listens on 5433 and forwards to 5432. Run this in a terminal:
Terminal window docker run -d --name toxiproxy --network host ghcr.io/shopify/toxiproxy:2.12.0curl -sf -X POST http://localhost:8474/proxies \-d '{"name":"postgres","listen":"127.0.0.1:5433","upstream":"127.0.0.1:5432"}'--network hostis the simplest on Linux. On Docker Desktop (macOS or Windows), publish ports 8474 and 5433 and usehost.docker.internal:5432as the upstream, unless you have enabled Docker Desktop’s host networking. -
Install the client:
npm install --save-dev toxiproxy-node-client(4.1.0 on 2026-09-26). -
Point the test pool at port 5433 and set a client-side query timeout. This is the gotcha the opening scenario hit: PostgreSQL’s
statement_timeoutmeasures execution time on the server, so latency injected on the network never trips it. Onlyquery_timeoutin node-postgres, which the client enforces, catches a slow network. -
Write the suite. Reset Toxiproxy after every test so a failed assertion never leaves the proxy poisoned for the next one:
test/resilience/db-faults.test.js import { Toxiproxy } from 'toxiproxy-node-client';import pg from 'pg';import { createOrder } from '../../src/services/order.service.js';const toxiproxy = new Toxiproxy('http://localhost:8474');const pool = new pg.Pool({host: '127.0.0.1',port: 5433, // through the proxy, never 5432database: 'app',user: 'postgres', // the CI service has only this rolequery_timeout: 2000, // client-side: trips on network latencyconnectionTimeoutMillis: 1000,});afterEach(() => toxiproxy.reset()); // removes every toxic, re-enables proxiesafterAll(() => pool.end());test('control: a reset connection breaks the query, so the proxy is in the path', async () => {const proxy = await toxiproxy.get('postgres');await proxy.addToxic({ name: 'reset', type: 'reset_peer', stream: 'downstream', toxicity: 1, attributes: { timeout: 0 } });await expect(pool.query('SELECT 1')).rejects.toThrow();});test('createOrder fails fast when PostgreSQL responses are 4 s late', async () => {const warm = await pool.connect(); // the pool is empty after the control testwarm.release(); // so open one now, or the connect timeout fires insteadconst proxy = await toxiproxy.get('postgres');await proxy.addToxic({ name: 'slow-db', type: 'latency', stream: 'downstream', toxicity: 1, attributes: { latency: 4000, jitter: 0 } });const start = performance.now();await expect(createOrder(pool, { sku: 'ABC-1', qty: 1 })).rejects.toThrow('Query read timeout');expect(performance.now() - start).toBeLessThan(2500);expect(pool.totalCount - pool.idleCount).toBe(0); // no client left checked out});
The examples use ES modules, so they assume Jest configured for ESM or TypeScript (ts-jest or babel-jest). Vitest runs them if you set test.globals: true (or import test, expect, afterEach and afterAll from vitest) and swap npx jest for npx vitest run.
The control test matters more than it looks. If someone points the pool back at 5432, every latency test still “passes” because the query returns quickly. The control test goes red instead.
The latency test warms the pool for a reason. After the control test the pool holds no live client, so without the warm-up the 4 s latency delays the PostgreSQL handshake. Then connectionTimeoutMillis fires first with timeout exceeded when trying to connect, and a loose /timeout/i assertion stays green even with query_timeout deleted. The exact Query read timeout message (node-postgres) rules that out. If you also want to prove the connect timeout, give it its own test that adds the latency toxic before any client exists and asserts timeout exceeded when trying to connect.
Now hand the rest of the matrix to your agent. The prompt is the same in every tool:
The tools differ in who starts the containers and how the result comes back to you.
Run claude in the repository and paste the prompt. Claude Code iterates on its own: generate, run, read the failure, add the missing timeout, run again. Start Toxiproxy yourself first (step 1) so the agent does not need Docker permissions. Save the prompt as a project skill in .claude/skills/resilience-suite/SKILL.md if you will run it for every service.
Paste the prompt into the Codex CLI (codex) or the IDE extension. Start Toxiproxy and PostgreSQL before the session. With the default approval policy (on-request), Codex asks before running a command its sandbox does not allow, so expect to approve npx jest. If the sandbox refuses the connection to Toxiproxy on localhost, run the test file yourself and paste the failure output back into the session.
Codex’s extra lever here is --output-schema on codex exec, which makes the final summary machine-readable for a pull request comment. codex exec cannot stop to ask for approval. The workspace-write sandbox also blocks network by default, so the -c override below opens it for this run. Without it, npx jest cannot reach Toxiproxy, and the agent may read the refused connection as a missing protection. The override opens all outbound network for the run, not only localhost, so use it only on a machine without production credentials. Permission profiles are the newer mechanism (beta in Codex CLI), but they do not compose with --sandbox, so pick one. Save the prompt above to prompts/resilience.md, then run this in a terminal:
codex exec --sandbox workspace-write \ -c sandbox_workspace_write.network_access=true \ --output-schema schemas/resilience-summary.json \ -o resilience-summary.json "$(cat prompts/resilience.md)"schemas/resilience-summary.json is a JSON Schema you write once: an object with a rows array of {row, test, protection, location} (the root must be an object), so CI can check that every matrix row maps to a test.
Give Cursor’s agent the prompt with the files @-mentioned (@docs/resilience/order-service.md @src/db/pool.ts), so it reads your real pool settings. Cursor runs the test command in its integrated terminal; start Toxiproxy (step 1) before you send the prompt. Check the proposed diff to pool.ts before you accept it: that file is the only production change this task should make.
Prove the circuit breaker opens without calling the network
Section titled “Prove the circuit breaker opens without calling the network”The common circuit-breaker test mocks the API to fail ten times, trips the breaker, and asserts nock.isDone() === false. That assertion proves nothing: with ten interceptors and six calls, isDone() is false whether or not the breaker blocked the sixth request. Count the upstream calls instead.
This test assumes the client exports its opossum breaker. opossum opens on an error percentage (errorThresholdPercentage, default 50) once volumeThreshold requests have been seen, not after a fixed count of consecutive failures, so the test reads the threshold from the breaker:
import nock from 'nock';import { getInventory, inventoryBreaker } from '../../src/clients/inventory.client.js';
afterEach(() => nock.cleanAll());
test('breaker opens, then serves the fallback with zero upstream calls', async () => { let upstreamCalls = 0; nock('https://inventory.internal') .get(/.*/) .times(50) .reply(() => { upstreamCalls += 1; return [500, { error: 'boom' }]; });
const opened = new Promise((resolve) => inventoryBreaker.once('open', resolve)); const volume = Math.max(inventoryBreaker.options.volumeThreshold ?? 1, 1); for (let i = 0; i < volume; i += 1) { await getInventory('SKU-1').catch(() => {}); // the fallback may resolve these } await opened;
const callsWhenOpened = upstreamCalls; const result = await getInventory('SKU-1'); expect(result.source).toBe('fallback-cache'); expect(upstreamCalls).toBe(callsWhenOpened); // the breaker, not luck, stopped the call});Run system-level chaos against staging with Chaos Mesh
Section titled “Run system-level chaos against staging with Chaos Mesh”Once each service fails fast, check that the system recovers. Chaos Mesh (v2.8.4 on 2026-09-26) expresses every experiment as a Kubernetes custom resource, so the agent’s output is a manifest you can diff, validate, and bound. One network delay on the database, limited to the staging namespace and 10 minutes:
apiVersion: chaos-mesh.org/v1alpha1kind: NetworkChaosmetadata: name: postgres-latency namespace: stagingspec: action: delay mode: all selector: namespaces: [staging] labelSelectors: app: postgresql delay: latency: '200ms' jitter: '50ms' duration: '10m'A staged run chains several faults in a Workflow resource with templateType: Serial, inserting Suspend templates as recovery gaps and a deadline on each step. Validate every manifest against the CRD schemas installed in the cluster without creating anything:
kubectl apply --dry-run=server -n staging -f chaos/A client-side dry run (--dry-run=client) never reaches the API server. Chaos Mesh’s validating webhooks and server-side field validation never run, so it can pass manifests the cluster would reject. Ask the agent for the staged workflow and the runbook together, so the abort command is written before anyone applies anything:
Paste the prompt in a session that has kubectl access to staging; the dry run needs it.
To let Claude Code watch pods and events while an experiment runs, add the Kubernetes MCP server with a read-only config (read_only = true in the TOML file):
claude mcp add kubernetes -- npx -y kubernetes-mcp-server@latest --config ~/.config/k8s-mcp.tomlChaos manifests need no running cluster to write, so this is a good Codex cloud task (codex cloud is experimental in Codex CLI 0.157.1). Save the prompt to prompts/staged-chaos.md without its last two lines (the dry run needs cluster access), then submit it from the terminal:
codex cloud exec --env ENV_ID "$(cat prompts/staged-chaos.md)"codex cloud diff TASK_ID # review the manifestscodex cloud apply TASK_ID # bring them into your working treeENV_ID is your Codex cloud environment (browse them with codex cloud), and TASK_ID is printed by the first command. Run the server-side dry run locally after apply. To give the local CLI read-only cluster access, add the same MCP server with the same read-only TOML file: codex mcp add kubernetes -- npx -y kubernetes-mcp-server@latest --config ~/.config/k8s-mcp.toml.
Use the same prompt with @k8s/staging/ mentioned so Cursor’s agent reads your real labels. For cluster visibility, add the server to .cursor/mcp.json:
{ "mcpServers": { "kubernetes": { "command": "npx", "args": ["-y", "kubernetes-mcp-server@latest", "--config", "/path/to/k8s-mcp.toml"] } } }How do you know a fault test can fail?
Section titled “How do you know a fault test can fail?”A green resilience suite written by an agent is only evidence if each test would go red without the protection it claims to exercise. Check that mechanically, not by reading the tests:
- Negative control per protection. Remove each timeout, retry, or breaker in turn and run the suite; the matching test must fail. This is a targeted form of mutation testing, and oracle strength explains how to automate it with a mutation tool on changed files.
- An observable effect in every test. Every fault test asserts elapsed time, a specific error, or a call count. A bare
rejects.toThrow()passes on a typo. - The control test stays in the suite. It proves the traffic goes through the proxy.
- The tests are outside the agent’s edit scope. Put
test/resilience/anddocs/resilience/under CODEOWNERS so a later agent cannot relax a bound to get green; see protecting the oracle.
Who signs off. The service owner approves the fault matrix and the negative-control table in the pull request, which belongs in the evidence bundle. A tech lead or the on-call SRE approves each staging chaos run through the CI environment gate below, and records the steady-state baseline taken right before it.
Run fault tests in CI and gate staging chaos
Section titled “Run fault tests in CI and gate staging chaos”Application-layer tests run on every pull request. Staging chaos runs only on manual dispatch, after the tests pass, behind a GitHub environment whose required reviewers approve before the job receives the staging credentials.
name: Fault injectionon: pull_request: workflow_dispatch: inputs: experiment: description: Chaos Mesh experiment to apply to staging type: choice options: [postgres-latency, staged]
permissions: contents: read
jobs: resilience-tests: runs-on: ubuntu-latest services: postgres: image: postgres:17 env: POSTGRES_DB: app POSTGRES_HOST_AUTH_METHOD: trust # throwaway CI database only options: >- --health-cmd pg_isready --health-interval 5s --health-retries 10 toxiproxy: image: ghcr.io/shopify/toxiproxy:2.12.0 ports: ['8474:8474', '5433:5433'] steps: - uses: actions/checkout@v7 with: persist-credentials: false - uses: actions/setup-node@v7 with: node-version: 22 - run: npm ci - name: Route PostgreSQL through Toxiproxy run: >- curl -sf -X POST http://localhost:8474/proxies -d '{"name":"postgres","listen":"0.0.0.0:5433","upstream":"postgres:5432"}' - run: npx jest test/resilience/
staging-chaos: if: github.event_name == 'workflow_dispatch' needs: resilience-tests runs-on: ubuntu-latest environment: staging # required reviewers approve before secrets are exposed steps: - uses: actions/checkout@v7 with: persist-credentials: false - name: Write the staging-only kubeconfig env: KUBECONFIG_DATA: ${{ secrets.STAGING_CHAOS_KUBECONFIG }} run: printf '%s' "$KUBECONFIG_DATA" > "$RUNNER_TEMP/kubeconfig" - name: Abort if staging is already degraded env: KUBECONFIG: ${{ runner.temp }}/kubeconfig run: ./scripts/chaos-preflight.sh - name: Apply the experiment (spec.duration or deadlines bound it) env: KUBECONFIG: ${{ runner.temp }}/kubeconfig EXPERIMENT: ${{ inputs.experiment }} run: kubectl apply -n staging -f "chaos/${EXPERIMENT}.yaml"Three details carry the safety. The input is a choice and reaches the shell through an environment variable, never interpolated into the command. The kubeconfig belongs to a service account whose RBAC allows only chaos-mesh.org resources in the staging namespace. And scripts/chaos-preflight.sh is yours to write: it should exit non-zero when an alert is firing or an incident is open, so chaos never lands on an already unhealthy system. A dispatch runs the workflow and that script from whichever branch the dispatcher picks, so limit the staging environment’s deployment branches to the default branch; otherwise a feature branch can swap in its own preflight script and run it with the staging kubeconfig once a reviewer approves.