Load testing proves a service is fast enough. Neither that nor "it deploys" answers the question on-call actually loses sleep over: what happens when a dependency breaks? A stack accumulates resilience primitives — a circuit breaker, retry/timeout/bulkhead, a dead-letter topic, a fail-fast producer — but "the breaker will save us" is a claim in a design doc until you break the thing and watch. Chaos testing turns each claim into a controlled experiment and, better, into an assertion in a test that gates every push.
A chaos experiment is controlled, not random
This isn't "break things in prod". Each experiment has three parts: a steady-state hypothesis (a measurable property that must stay true even during the fault), the smallest blast radius that tests it, and a recovery you assert. OrderHub ships four faults that map 1:1 to JVM fault-injection tests (the CI half) and Chaos Mesh manifests (the game-day half):
- Dead downstream → the circuit breaker OPENs, fast-fails to a degraded fallback, then recovers CLOSED.
-
Injected latency → a
@TimeLimitercuts off the slow call and a@Bulkheadrejects overflow, so the caller is never blocked for the full latency. -
Unreachable broker → the producer fails fast (
max.block.ms=2s), the order still stands, and publishing resumes when the broker returns. -
Poison message → retried a bounded number of times, then routed to a
.DLTtopic — never dropped, never an infinite loop.
The CI half: fault-injection tests that gate the build
The circuit-breaker experiment drives the real Resilience4j state machine with a tiny window so recovery is observable in milliseconds. It doesn't stop at "it opened" — it asserts graceful degradation and a clean recovery.
client.setFailureRate(1.0); // 💥 downstream is DOWN
for (int i = 0; i < 5; i++) guarded(breaker, client, "Keyboard");
assertThat(breaker.getState()).isEqualTo(OPEN); // tripped
client.resetCallCount(); // steady state during the fault:
for (int i = 0; i < 5; i++) {
InventoryStatus s = guarded(breaker, client, "Keyboard");
assertThat(s.degraded()).isTrue(); // graceful fallback, never a 500
}
assertThat(client.callCount()).isZero(); // OPEN -> the dead dep is never called
client.setFailureRate(0.0); // 🩹 downstream recovers
Thread.sleep(400); // > waitDurationInOpenState
guarded(breaker, client, "Keyboard"); // HALF_OPEN trial calls succeed
guarded(breaker, client, "Keyboard");
assertThat(breaker.getState()).isEqualTo(CLOSED); // recovered
The poison-message test builds the recovery policy explicitly and proves the retries are bounded — the failure mode to avoid is a record retried forever, blocking its partition:
DefaultErrorHandler handler = new DefaultErrorHandler(recoverer, new FixedBackOff(0L, 2L));
// ... a listener that ALWAYS throws for the poison record
template.send("chaos-poison", "ORD-POISON", "{...POISON-SKU...}");
ConsumerRecord<..> dead = getSingleRecord(dltConsumer, "chaos-poison.DLT", ofSeconds(20));
assertThat(dead.key()).isEqualTo("ORD-POISON"); // routed to .DLT, key preserved
assertThat(attempts.get()).isEqualTo(3); // 1 initial + 2 retries — BOUNDED
The game-day half: chaos as code
The same hypotheses become Chaos Mesh manifests you run against a real cluster. Where the JVM test proves the breaker logic, a PodChaos proves it against real pod rescheduling and kubelet timing — things an in-process test can't model. The manifest makes the blast radius explicit: one random pod, for a bounded duration, auto-reverted.
kind: PodChaos
spec:
action: pod-kill
mode: one # blast radius: ONE random pod, not the Deployment
selector:
labelSelectors: { app: inventory-service }
gracePeriod: 0 # hard kill — the worst case for the caller
duration: 60s # bounded: Chaos Mesh reverts automatically
Config rots, so even the chaos is tested: a ChaosManifestTest parses every manifest with SnakeYAML and asserts each is a well-formed, bounded, scoped experiment that documents a steady-state hypothesis. Drop a duration (an experiment that never reverts is dangerous), widen a selector, or delete the hypothesis comment and the build goes red.
Chaos in CI catches regressions on every push; the game-day catches what only real infrastructure can. Together, resilience stops being a claim and becomes evidence — 5 fault-injection tests plus 4 manifest-shape tests, mvn -B clean test → BUILD SUCCESS.
Pick a fault, press inject, and watch the steady-state hypothesis hold and then recover: https://dev48v.infy.uk/orderhub/day48-chaos-testing.html
Top comments (0)