New: Debug encrypted microservice traffic with Speedscale's eBPF collector Read the announcement

Application Level Dependency Chaos Testing

Application Level Dependency Chaos Testing


Somewhere in your service is a branch that has never executed. Not a rare one, a never one. It handles a dependency being unavailable: it reads from a cache, it returns a stale value, it marks the response degraded so callers know not to trust it too far.

It was written carefully. It was reviewed. Whether it works is an open question, because nothing in the test suite makes that dependency fail, and the dependency does not fail on request. The first execution of that code will be during an outage, which is the single worst time to discover a bug in it.

This guide closes that question in one command, using a lab that ships with a real bug in exactly that branch.

Chaos engineering, one layer down

What follows is a chaos experiment in the ordinary sense. The Principles of Chaos Engineering define the discipline as experimenting on a system to build confidence that it withstands turbulent conditions, and prescribe a method: establish a steady state, hypothesize it holds, introduce a variable, then look for the difference. Every step below maps onto that. The steady state is six SKUs answering with fresh inventory data. The variable is one dependency failing. The difference is the whole point.

The variable is where this parts company with most chaos tooling. Read the canonical list of disruptions to introduce and they are all infrastructure: “servers that crash, hard drives that malfunction, network connections that are severed.” The tools built over the last decade reflect that. Netflix’s Chaos Monkey started by terminating instances. LitmusChaos and Chaos Mesh, both CNCF incubating projects, kill pods, throttle bandwidth, exhaust CPU, and corrupt disk IO. Gremlin and AWS Fault Injection Service do the same commercially and inside one cloud.

That work is genuinely valuable and this does not replace it. But notice what the whole category is good at: breaking the platform your service runs on. The failures it produces are the ones your service can usually see coming, because they arrive as timeouts, connection errors, and missing endpoints, and defensive code handles those reasonably well.

Chaos Mesh gets closest to the layer this guide works at: its HTTPChaos can abort or delay HTTP calls. Even then you describe the fault abstractly, in a spec. What none of these tools do is take the response your dependency actually sent, on a real day, and perturb that.

That is the gap this fills. The chaos here is injected into recorded dependency traffic, one endpoint at a time, which makes the blast radius a filter query instead of a namespace.

What you will build

The companion chaos lab is a storefront that answers GET /api/stock/{sku} by asking an inventory service how many units are on hand. If inventory is unavailable it falls back to the last good answer it saw and marks the response degraded.

flowchart LR
  C["client"] --> S["storefront<br/>:8080"]
  S --> P["proxymock<br/>mock server"]
  P --> I["recorded inventory<br/>responses"]
  P -. "chaos rule<br/>scoped to /v1/inventory" .-> P
  S --> K["lastKnown cache<br/>(the branch that never runs)"]

You will record one ordinary session where nothing fails, then make inventory and only inventory fail on every call, and read what the storefront claims against what inventory actually did. The two do not agree, and the disagreement is the bug.

Prerequisites

  • Go 1.23 or newer, and curl
  • proxymock v2.5.892 or newer, installed, initialized, and on PATH
  • No Docker, no cluster, no Speedscale account. Every command here is local.

Clone mock-lab and work from its chaos directory. The storefront binds 127.0.0.1:8080 and the inventory fixture localhost:8090; proxymock’s proxy is on 4140 and its health endpoint on 4141.

1. Record one ordinary session

make capture

The inventory fixture starts, proxymock record runs the storefront as a child process, six SKUs are looked up, and everything stops. You get six inbound stock lookups and six outbound inventory calls.

Nothing rare is in this recording, deliberately. Every SKU resolves, inventory answers 200 every time, and there is no failure anywhere in it. This matters: the failure you are about to inject does not need to have happened on the day the traffic was captured. That is the difference between chaos and waiting for luck.

2. Watch the storefront work

make mock          # leave this running
make baseline      # in a second terminal

Six healthy answers:

{"sku":"SSC-4110","available":42,"in_stock":true,"degraded":false,"source":"inventory"}

This is the state every test suite has ever seen, and every one of them passed.

3. Take inventory down, and nothing else

Stop make mock, then:

make mock-chaos    # leave this running
make baseline      # in a second terminal

The whole intervention is one flag:

--chaos '(url CONTAINS "/v1/inventory"): status=503,percent=100'

Everything before the colon selects traffic and everything after it decides what happens to it. The scope is a filter query, the same syntax the Requests grid and --query-string use, and every group must be parenthesized. It selects the outbound inventory calls and nothing else in the recording.

Now check what inventory is really saying:

make chaos-evidence
HTTP/1.1 503 Service Unavailable
X-Speedscale-Chaos: effect=status code;status=503;rule=chaos-1

That header is how you tell an injected failure from a real one. It names the effect and the rule that fired, it is absent on untouched responses, and it is persisted onto the recorded pair, so it stays readable long after the terminal scrollback is gone.

And here is the storefront’s answer for all six SKUs, with its only dependency returning 503 on every single call:

{"sku":"SSC-4110","available":42,"in_stock":true,"degraded":false,"source":"inventory"}

degraded:false. source:"inventory". Not a warning in the log. The fallback cache, which exists and is correct, never ran.

4. Reconcile the two facts

Two things are true at once:

  • inventory returned 503 on every call, with a marker on each one to prove it
  • the storefront reported fresh, undegraded data from inventory for every SKU

Notice what did not change: the response contract. Same fields, same types, same 200 OK to the client. No status assertion catches this. No response diff between releases catches it. The numbers are not even wrong, because they are the recorded body, which a 503 does not erase.

The lie is in the metadata. The storefront told its callers this data was current while its dependency was down.

The cause is four lines up the stack:

resp, err := client.Get(fmt.Sprintf("%s/v1/inventory/%s", base, sku))
if err != nil {
	return level, fmt.Errorf("calling inventory: %w", err)
}
defer resp.Body.Close()

if err := json.NewDecoder(resp.Body).Decode(&level); err != nil {

err is only non-nil when the request could not be completed at all. A 503 is a completed request. The status is never read, the body decodes cleanly, and the caller gets a stockLevel and a nil error.

This is why pointing a service at a dead port is a weaker test than it looks. A dead port produces a transport error, which is the one failure mode this code does handle. Chaos here injects the failure mode that looks like success.

5. See it in proxymock web

The header on the wire is one view. The run itself is the other.

make mock-chaos-record    # leave running
make baseline             # a few times, in a second terminal
make web                  # in a third

Open http://127.0.0.1:7788 and go to Requests.

Then use the RUN dropdown in the toolbar to select a results/mocked-* directory. This is the step to get right. The dropdown defaults to recording, which is the session you captured in step 1, back when nothing failed. There is no chaos in it and there never will be, so a reader who skips this sees an empty Chaos column and concludes the feature does not work. The perturbed traffic is in the run you just produced, not in the recording it was served from.

With the right run selected, the Chaos column marks every perturbed response, the toolbar filter narrows to just those, and opening a row names the rule that fired and what it changed.

The proxymock web Requests grid with a results run selected: twelve inventory calls, five of them showing a red 503 over a struck-through 200 and a status code chaos pill

This variant uses percent=50, so the grid holds both kinds of row and the filter has something to do. That is the view worth having: injected failures are labelled, so an injected 503 is never mistaken for a real one.

Two details in that grid are worth a second look. The MATCH column reads match on the chaosed rows as well as the healthy ones, which is correct: chaos is applied after the request matches a recorded pair, so a perturbed response is always a mock hit and never a miss. And the counter reads Chaos applied (5) out of twelve requests rather than exactly half, because percent=50 is a per-request probability, not a quota.

One thing looks like a disagreement and is not. The STATUS column shows 503, what the client actually received, with a tooltip reading Chaos sent 503; the mock recorded 200. The file on disk still says 200. Both numbers are true and the grid keeps both, because the recorded pair is mock input for a later run and rewriting it would change what a re-replay does.

6. Fix it, and prove both properties

The fix is the smallest possible one: read the status. Open cmd/app/main.go, find fetchStock, and add three lines between the defer and the decode:

	defer resp.Body.Close()

	// Add this. A 503 is a completed request, so err is nil and the body
	// below decodes cleanly into a stockLevel that means nothing.
	if resp.StatusCode != http.StatusOK {
		return level, fmt.Errorf("inventory returned %d", resp.StatusCode)
	}

	if err := json.NewDecoder(resp.Body).Decode(&level); err != nil {

No new imports: net/http and fmt are both already in the file. That is the entire fix, and it is the whole reason the rest of this exercise is worth doing, because nothing about the response contract told you those three lines were missing.

Now prove it. Stop whichever make mock-* process is still running, then start the flaky variant rather than the total outage:

make mock-flaky    # leave running
make baseline      # a few times, in a second terminal

Every make mock-* target rebuilds the binary first, so your edit takes effect on that command. There is no separate build step.

That target runs this rule:

--chaos '(url CONTAINS "/v1/inventory"): status=503,percent=50,seed=lab'

Half the calls fail, so a single run exercises the healthy path, the degraded path, and the transition between them, which the total outage in step 3 cannot do. Run make baseline a few times and watch the answers change: a fixed storefront produces all three states below and never claims degraded:false on a call that failed.

inventory failed, nothing cached yet
  {"error":"inventory unavailable"}

inventory answered, cache updated
  {"sku":"SSC-4110","available":42,"in_stock":true,"degraded":false,"source":"inventory"}

inventory failed, cache served and labelled
  {"sku":"SSC-4110","available":42,"in_stock":true,"degraded":true,"source":"cache"}

The third line is the one that was missing before. Same SKU, same number, and now the response says where the number came from and that it should not be trusted as current.

Two properties, both proven by that output. The fallback runs, and it tells the truth about itself.

Two things that look like chaos stopped working

The errors disappear after the first run. The first make baseline hits SKUs that have never resolved, so a chaosed call has nothing to fall back to and returns {"error":"inventory unavailable"}. That run also caches every SKU that succeeded, so on later runs a chaosed call serves the cached value instead. The error state is the cold-cache state, and you only get it once.

A run with no errors in it is not a run with no chaos in it. After the fix, a chaosed call still returns 200 with a body. The signal moved: look for "degraded":true,"source":"cache" rather than for an error. Two of those lines means two calls were perturbed, which is exactly what you were trying to prove. Before the fix there was no signal at all, which was the bug.

The pattern also changes every time you run it, and that is not chaos being random. The roll is keyed on an occurrence counter kept per rule and signature inside the responder process, so your first make baseline is occurrence 0 for each SKU and your second is occurrence 1. seed=lab means that restarting the mock server and replaying the same sequence gives the same verdicts, not that every invocation against a running server gives identical answers.

To check the wire directly rather than inferring from the storefront’s answers:

make chaos-evidence

That prints the X-Speedscale-Chaos header straight off the mock server, which is the authority on whether a given response was perturbed.

On reproducibility

seed=lab makes the run repeatable, with a limit worth stating rather than discovering.

The roll is a pure function of the rule, the request signature, and the occurrence count, with no clock in it. The Nth lookup of a given SKU always gets the same verdict. It is not a promise that two runs are bit-identical: a run that issues a different number of requests for a signature diverges after that point.

The unit that repeats is the responder process, not the command you type. That counter lives in the running mock server and keeps incrementing across every make baseline you fire at it, which is why the pattern moves each time. Restart the server and send the same sequence, and the verdicts come back identical.

That is stronger than ordering-based reproducibility, which is worthless the moment the responder serves requests concurrently, and weaker than full determinism. In practice it means a failure you find this way is one you can hand to a teammate along with the command that produced it.

What this does not do

It does not hide the consequences. If the storefront cannot absorb the injected failure, the failure is reported normally, because that is the entire question you came to answer. Chaos-affected traffic is excluded from drift and match-rate analysis, since an injected 503 is not mock drift, but never from pass or fail.

It also is not a substitute for killing pods. Infrastructure chaos and application chaos find different bug classes, and the honest position is to run both.

Clean up

make clean

Stop the make mock-* process. Nothing else is left running, and nothing was installed outside the lab directory.

The reference documentation covers the full scope syntax, the rest of the effects, and the rule editor. If you want the argument for why this layer is worth testing separately, that is in Break One Dependency, Not The Whole Cluster.

Stop writing API mocks by hand

proxymock records real traffic from your running app and replays it as mocks — HTTP, gRPC, Postgres, Kafka, and more. Install in 30 seconds, no account required.