Back to Blog
DDoSMicroservicesService MeshTesting

DDoS Testing for Microservices: Where the Failure Is Internal

BlackNeuron Research Team
September 18, 2026
12 min read
DDoS Testing for Microservices: Where the Failure Is Internal

An edge DDoS test asks whether the perimeter can absorb a flood. DDoS testing for microservices asks the question the perimeter cannot answer: when one internal service slows down, does the problem stay with that service, or does it travel?

In a monolith there is nowhere for it to travel. One process serves the request end to end, and if it is overloaded it is slow for everyone at once, which is at least easy to see. Break that process into thirty services that call each other over the network, and you have built a graph of dependencies where the interesting failures happen between the nodes, not at any one of them.

The flood that takes a microservice architecture down is rarely a flood at all. It is one dependency getting slow, a caller that does not handle the slowness well, and a retry policy that turns a small stall into a spreading one. None of it is visible at the edge, because the edge held. The requests got in. What happened next is the subject of this post.

What DDoS testing for microservices is

DDoS testing for microservices is a controlled test of how a distributed system behaves when one internal service degrades under load: whether the slowdown stays contained to that service, or propagates across the call graph through shared connection pools, retries, and timeouts until unrelated services fail too.

Scoping a test, getting it authorized, bounding its blast radius, and reading its metrics are common to every DDoS testing engagement and are covered in the complete guide to DDoS testing. The public entry points, per-endpoint cost, auth paths, and external client behavior belong to DDoS testing for APIs. This post assumes both and spends its words only on what changes once a request clears the edge and starts fanning out across services you also own.

The edge is not where it fails

The traffic an edge test cares about is north-south: clients on the outside, your system on the inside, one boundary between them. That is where a CDN, a WAF, and a rate limiter live, and testing it answers a real question about how much external volume the front door absorbs.

Microservice failures are east-west. They live in the traffic between your own services, which never crosses the edge and never shows up in an edge test. You can pass a volumetric test at the perimeter with room to spare and still have an architecture that falls over the moment one internal service gets slow, because the two tests measure different systems.

This is why "we load tested the API gateway" is not an answer. The gateway can hold its rated throughput perfectly while, three hops behind it, a saturated dependency is quietly taking down every service that calls it. The layer that fails first is almost never the one facing the internet.

So the object under test is the call graph itself. Not "how much can the front door take," but "when a node in the middle degrades, how far does the damage reach before something stops it." That reach is the internal blast radius, and it is the number DDoS testing for microservices exists to produce.

Connection pools: slowness travels upstream

The first mechanism that spreads a local slowdown is the connection pool, and it spreads it in the direction nobody watches: backward, up the call graph, against the flow of requests.

Service A calls service B over a pool of, say, fifty connections. Under normal latency each call returns in a few milliseconds, so fifty connections are plenty to serve thousands of requests a second. Now B gets slow: a dependency of its own is struggling, or it is under a targeted load, and its responses stretch from five milliseconds to five seconds. A's connections to B are now held open a thousand times longer. Within moments all fifty are in use and waiting, and A has no connection left to make a new call to B.

Here is the part that turns one service's problem into everyone's. A's threads are now blocked waiting on that pool. They cannot serve A's other work either, including requests that have nothing to do with B. A is healthy, its own dependencies are fine, and it is failing anyway, because it is out of the resource it needs to talk to a downstream that got slow. The slowness has propagated from B to A without B ever going down.

2026-09-17T14:33:48.354593 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/ 0 10 20 30 40 50 60 connections in use pool cap: 50 B's latency: 5 ms → 5 s Service A, 200 calls/s to B over 50 connections. In use = rate × latency (Little's law), capped at the pool. One slow downstream pins the caller's pool in seconds Connections to B in use (pool of 50) 0 10 20 30 40 50 60 seconds 0 20 40 60 80 100 unrelated requests served (%) A is healthy and B never went down. A's 100 worker threads park behind the full pool at 200/s. Isolated pool (bulkhead): unrelated work keeps serving Shared worker threads: unrelated work starves BlackNeuron
Two-panel chart of one slow downstream pinning the caller's connection pool. Service A sends service B 200 calls per second over a pool of 50 connections. At twenty seconds B's latency grows from 5 milliseconds to 5 seconds, and by Little's law the connections in use climb from about one to the pool cap of 50 within a few seconds. The lower panel shows the share of A's unrelated requests, the ones that never needed B, that still get served: with an isolated pool, a bulkhead, it stays at 100 percent; with shared worker threads it collapses to near zero within seconds, because every thread parks behind the full pool. A is healthy and B never went down.

Multiply that up a graph where A is itself somebody's downstream, and the stall climbs hop by hop toward the edge. This is the internal cousin of connection-table exhaustion and the accept queue filling at the kernel: the same shape, a finite pool of connection slots consumed faster than they drain, one layer up in the application. The node-level and orchestration-level versions of this, node conntrack limits, ingress saturation, autoscaler behavior, are the subject of Kubernetes DDoS testing; the application-pool version is the one a microservices DDoS test drives directly.

The test is to make one downstream slow on purpose, latency injection rather than a flood, and watch whether the caller's pool saturates and whether that saturation is contained to calls that need the slow service or spreads to unrelated work. If a slow recommendations service can stall the checkout path, the pools are not isolated, and that is a finding no amount of edge capacity fixes.

Retry amplification multiplies at every hop

The second mechanism is retries, and in a deep call graph they do something an external client cannot: they multiply.

When an external client retries a failed request, it adds load linearly, one client, a few extra requests. That external retry storm is real and it belongs to the API post. Between services the arithmetic is different, because retries stack at every level of the call stack. If the edge service retries a failed call three times, and the service it calls also retries its own downstream three times, and that service retries once more, a single user request can arrive at the deepest dependency as nine, twenty-seven, or more inbound calls. The multiplier is the product of the per-hop retry counts, not their sum.

One request in, twenty-seven at the bottom Each hop retries up to 3 times. The load multiplies down the graph; it does not add. 1 Edge service retries B up to 3x 3 Service B 3 inbound retries C up to 3x 9 Service C 9 inbound retries DB up to 3x 27 Shared database 27 inbound calls least spare capacity The multiplier is the product of the per-hop retry counts, not their sum. It is largest exactly where capacity is smallest, at the shared data store, and it fires when that store is already struggling. BlackNeuron
Retry amplification cascading inward across three service hops. One user request enters the edge service. The edge retries its call to service B up to three times, so three requests reach B. B retries its call to service C up to three times per request, so nine requests reach C. C retries its call to the database up to three times per request, so twenty-seven requests reach the database. The label reads: one user request becomes twenty-seven at the deepest dependency. The multiplier is the product of the per-hop retry counts, and it is largest exactly where capacity is smallest, at the shared data store.

The cruelty of it is the timing. Retries fire precisely when a downstream is already struggling, so the layer with the least spare capacity, usually the shared database or a single stateful service everyone depends on, receives the amplified load at the worst possible moment. A dependency that dips under normal load recovers; the same dip under a retry-amplified graph turns into a self-sustaining overload that outlives its trigger, because the retries themselves are now the load.

Testing this means driving a modest failure rate at one service and measuring the load multiplier at the services behind it: how many inbound calls each deep dependency sees per user request when the graph is degraded versus healthy. The fixes are well known, budget-limited retries (a cap on total attempts across the call chain, not per hop), backoff with jitter, and idempotency so a retry is safe, but whether they are actually configured, and configured consistently across every service, is exactly what the test confirms. One service with a naive retry loop reintroduces the amplifier for the whole path.

2026-09-17T14:33:48.633968 image/svg+xml Matplotlib v3.10.9, https://matplotlib.org/ 0 25 50 75 100 failure rate at the deepest dependency (%) 0 5 10 15 20 25 30 calls reaching it, per user request ×3 ×9 ×27 ×3 Each hop retries up to 3 attempts: expected calls = (1 + p + p²) per hop, multiplied across hops. Model, not a measurement. Retries multiply exactly when the dependency is failing Per-hop retries, 1 hop Per-hop retries, 2 hops Per-hop retries, 3 hops Chain-wide retry budget of 3, any depth BlackNeuron
Line chart of calls reaching the deepest dependency per user request as that dependency's failure rate rises from zero to one hundred percent, with each hop retrying up to three attempts. With per-hop retries the curves compound: one hop reaches three times, two hops nine times, three hops twenty-seven times at full failure, and the three-hop curve bends upward fastest exactly as the dependency degrades. A chain-wide retry budget of three, applied at any depth, stays flat at three. The model is stated on the chart: expected calls per hop are one plus p plus p squared, multiplied across hops; it is a model, not a measurement.

Circuit breakers: the control you set on paper and never fired

A circuit breaker is meant to stop the cascade. When calls to a downstream start failing or timing out past a threshold, the breaker opens and the caller fails fast instead of piling up blocked threads and retries against a service that is already down. It is the single most important containment control in a distributed system, and it is almost never tested under the conditions that make it matter.

It disappoints in three ways, and each one is its own test.

It never trips. The threshold is set on a metric that does not move the way the outage does. A breaker keyed on connection errors will not open for a downstream that is slow but still answering, and slow-but-answering is the common case, the one that fills pools. The breaker sits closed while the cascade it exists to prevent runs underneath it.

It trips too late. By the time the failure rate crosses the threshold and the breaker opens, the caller's pool is already saturated and its threads are already blocked, so opening the breaker frees nothing that was stuck. A breaker that reacts on the same clock as the failure it is meant to preempt is a monitoring tool, not a control.

It trips, then re-triggers the outage. A half-open breaker sends probe traffic to test whether the downstream has recovered. If every caller's breaker half-opens on the same schedule, they collectively hammer a service that had just started to breathe, and knock it back down, a synchronized thundering herd aimed inward. Recovery becomes oscillation.

The test is to open the breaker for real: drive the downstream past the threshold, confirm the breaker opens within a useful time, confirm the caller then fails fast rather than blocking, and confirm the half-open recovery does not re-collapse the service. A breaker whose thresholds have never been exercised against real degradation is a configuration, not a control.

The service mesh adds a second set of knobs

If the architecture runs on a service mesh, Istio, Linkerd, Consul, or a hand-rolled sidecar fleet, then every one of the behaviors above exists twice: once in the application, once in the sidecar proxy. That is the mesh's whole value, retries, timeouts, circuit breaking, and outlier detection pulled out of application code and made uniform, but it also means the knobs interact, and the interaction is where tests find surprises.

Retries are the sharp edge. If the mesh has a default retry policy and the application also retries, the two compose: the application's three attempts each become the mesh's three attempts, and you are back to nine where you thought you had three, with the mesh cheerfully amplifying inside what the application already amplified. A retry budget set in one layer means nothing if the other layer is retrying underneath it.

Timeouts have to be ordered, and usually are not. If a caller's timeout is shorter than the timeout the sidecar enforces on the downstream, the caller gives up and retries while the original call is still in flight, so the work is duplicated rather than abandoned. Timeouts should tighten as you go deeper into the graph, each hop allowing less time than its caller; when they are set independently by different teams, they rarely do.

Outlier detection, the mesh ejecting an instance that returns errors, can also shrink a pool at the worst time: eject enough slow instances and the remaining healthy ones inherit all the load and tip over in turn, ejection as a cascade. The upside is that the same mesh is the cleanest fault-injection tool you have, which the next section uses.

Testing from the inside: fault injection, not a flood

You cannot find any of this by pointing a load generator at the front door, because the front door is not where it breaks. DDoS testing for microservices is done from inside the graph, by degrading one service and watching how far the degradation reaches.

The primary technique is fault injection: introduce latency or errors at a chosen service and observe propagation. Add two seconds of delay to one dependency's responses and watch whether its callers' pools saturate. Return errors from one instance and watch whether retries amplify and whether breakers open. A service mesh does this natively, latency and abort faults are a configuration, which is one honest reason the mesh earns its complexity: the thing that adds knobs also lets you test them.

What you measure is containment. When one service degrades, the good outcome is that the damage stops at its direct callers and unrelated paths keep serving, the property that dedicated pools, bulkheads, and fail-fast breakers are supposed to provide. The bad outcome is that a slowdown in one non-critical service, a recommendation engine, an analytics sink, a preferences lookup, takes down a critical path that did not need it. The test result is that boundary: which services can fail without taking others with them, and which cannot.

Containment is the test result Recommendations gets slow. Does the checkout path, which never calls it, keep serving? Shared worker pool: the slowdown travels Checkout critical path: Payments Search calls Recommendations One worker pool, 100 threads: 100 blocked all parked waiting on a connection to Recommendations Payments healthy, 5 ms, unreachable Recommendations slow: 5 s per call Checkout fails while Payments is healthy: a path that never needed it went down Bulkheads, one pool per downstream: the slowdown stops Checkout critical path: Payments Search calls Recommendations Payments pool 2 of 20 in use Recommendations pool 20 of 20 blocked: fail fast Payments healthy, 5 ms, serving Recommendations slow: 5 s per call Checkout serves, Search fails fast: the blast radius stops at the pool boundary BlackNeuron
Two-panel diagram of containment under a DDoS test. Left panel, a shared worker pool: Checkout, whose critical path calls Payments, and Search, which calls Recommendations, both draw from one worker pool of one hundred threads. Recommendations gets slow at five seconds per call, all one hundred threads park waiting on a connection to it, and Checkout fails while Payments is healthy at five milliseconds, a path that never needed the slow service went down. Right panel, bulkheads with one pool per downstream: the Payments pool has two of twenty connections in use and Checkout keeps serving; the Recommendations pool has twenty of twenty blocked and fails fast, so Search degrades. The blast radius stops at the pool boundary.

The metrics are the familiar ones read against the internal system: availability of each critical path while a chosen dependency is degraded, and time to recover once the injected fault is removed, which is where retry storms and half-open breakers show up as a recovery that lags long after the fault is gone.

Running it without becoming the outage

Fault injection is deliberately breaking part of a running system, so the containment discipline matters more here, not less. The general mechanics, an explicit scope, a rate and blast-radius ceiling, live instrumentation, and a kill switch you fired this session, are covered in running a DDoS test without disrupting production, and the decisions and confirmations that precede launch are the test plan and the testing checklist.

Two points are specific to testing inside the graph. First, if the mesh you inject faults with is shared across environments, a fault rule scoped too broadly hits real traffic, so faults must be pinned to test workloads by header or namespace, and the abort must remove the rule, not just stop sending. Second, the whole value of the test is the call topology, so it has to run against a faithful replica of the graph. A staging environment with three of the thirty services stubbed out does not have the dependency that actually cascades, and will report healthy while production would not. If you cannot reproduce the graph, you have not reproduced the system.

FAQ

What is DDoS testing for microservices?

It is a controlled test of how a distributed system behaves when one internal service degrades under load, measuring whether the slowdown stays contained or propagates across the call graph. The mechanism is usually not raw volume at the edge but connection-pool saturation, retry amplification between services, and circuit breakers that do not trip in time, so the test degrades an internal service on purpose and measures how far the damage reaches.

How is it different from testing an API or a website?

A website or API test measures north-south traffic, external clients against your edge, and answers how much volume the front door absorbs. DDoS testing for microservices measures east-west traffic between your own services, which never crosses the edge. A system can pass the perimeter test and still collapse internally when one dependency slows, because the two tests measure different systems.

Why does one slow service take down others?

Because callers hold a finite pool of connections to each downstream. When a downstream gets slow, its callers' connections stay open longer, the pool saturates, and the caller's threads block waiting for a free connection, so the caller starts failing even for work unrelated to the slow service. Retries stacked at each hop multiply the load on the struggling dependency, and if circuit breakers do not open in time, the stall climbs the call graph toward the edge.

What is retry amplification?

Retry amplification is the multiplication of load when retries stack across a call chain. If each of three hops retries up to three times, one user request can reach the deepest dependency as up to twenty-seven calls, the product of the per-hop counts. It is worst exactly when a downstream is already struggling, because that is when retries fire, and it is contained with call-chain-wide retry budgets, backoff with jitter, and idempotency, whose presence the test verifies.

Can a service mesh prevent this on its own?

A mesh gives you uniform retries, timeouts, circuit breaking, and outlier detection, and it is the cleanest way to inject faults for testing. It does not prevent the failures by default, and it can worsen them: mesh retries compose with application retries, mesh and application timeouts set independently duplicate work, and outlier ejection can shrink a pool until the survivors tip over. The controls have to be configured coherently across both layers and then exercised, which is what the test does.

The blast radius is drawn by the call graph, not the perimeter

Perimeter thinking treats resilience as a wall: make the front door strong enough and the inside is safe. Distributed systems do not work that way. The inside is not one room behind the wall; it is a network of rooms with doors between them, and the failure that matters is the one that spreads from room to room while the wall stands untouched.

That spread has a shape, and the shape is the dependency graph. Which service calls which, over what pool, with what timeout, behind what breaker: those edges decide whether a slow node is a contained incident or a full outage, and none of them are visible from outside. An attacker probing for the cheapest way to cause the most damage is mapping that graph from the symptoms. The point of DDoS testing from the inside is to hold the map first, and to know, before it is tested for you, which single degraded service your architecture cannot survive.