Testing what your service does when the network fails

networkresilienceblack-box testing
Contents

New in tomato’s next release. The network resource is merged on main and ships with the next release. Until then, build tomato from main to try it.

Every service has code for a bad network: a query timeout, a retry loop, a cache that’s allowed to fail. Almost none of it runs in tests. On localhost the database answers in a millisecond, so the timeout never fires, the retry never retries, and the fallback never falls back. The first real run is the night the database fails over.

tomato’s network resource changes that. It sits between your service and a dependency’s container, and a step breaks the link: slow it down, hang it, cut it, take it down. Then you check what the service did. Pick a fault:

tomato sends requests to the app and applies a fault to its proxy between the app and Postgres, which can delay, swallow, reset, refuse or drop the app's queries; tomato then checks Postgres directly, past the proxy.your apporders · node app.jslast request201 in 4 msconnections opened1DATABASE_URL → the proxypostgres-networktomato's proxyhealthypasses everythingpostgrescontainerstaterunningrows in orders0query →← replyorders-client · HTTPapplies the faultorders-db · past the proxytomato runthe scenario's stepsthen checks the response, and orders-db, directly

"postgres-network" is healthy: queries and replies pass untouched. Every scenario starts here: tomato heals the network and forgets its counts before each one.

adds latency "300ms" with jitter "100ms": every reply arrives late, on the connections already open too. Does the endpoint still meet its budget? Does a cache keep it fast?

limits bandwidth to "64KB/s": replies trickle in, as over a congested link. Large result sets and file downloads show it first.

stops responding: queries still reach Postgres and run, but no reply comes back. Without a timeout the app waits forever, and so does its caller. With one, it answers 503, though the row may be written already.

resets connections for "2s": every connection is cut (TCP RST) the moment it carries data, as in a crash or a failover. The pool's connection dies; does the app open a new one and try again?

refuses connections: open connections close and new ones are refused, as when the database is down. The container keeps running and keeps its data, so the scenario can check it.

flaps every "2s" for "10s": down, up, down, up, like a database restarting in a loop. Retries that give up too soon, or never back off, show here.

fails the next "2" connections: two new connections close as soon as they open, then everything passes, like a dependency coming back up. has seen "3" connections proves the app retried.

loses "30%" of the data: chunks of the replies vanish, so the client stalls or reads a garbled protocol. Not TCP packet loss, which TCP would repair.

How it fits in

Add one network resource per dependency you want to break:

resources:
  orders-db:
    type: postgres
    container: postgres
    database: orders
    options: { user: orders, password: orders }

  postgres-network:               # the app's link to Postgres
    type: network
    container: postgres
  redis-network:                  # and to Redis
    type: network
    container: redis

Nothing else changes. The service’s DATABASE_URL still uses {{.postgres.port.5432}}, which now resolves to the proxy. tomato prints where each one listens:

  ⇄ postgres-network  app → 127.0.0.1:61442 → postgres:5432  (seed 98866)
  ⇄ redis-network     app → 127.0.0.1:61443 → redis:6379     (seed 253200)

Three things make it work for testing:

  • Only the service goes through it. tomato’s own resources, like orders-db, connect to the container directly. While the service’s link is down, a step can still read the table and see what really happened.
  • It speaks TCP, not a protocol. The same steps work for Postgres, Redis, Kafka, RabbitMQ, MinIO, ScyllaDB, or anything else in a container. With the kafka preset, tomato even makes the broker advertise the proxy, so the client can’t route around it.
  • Every scenario starts healthy. Before each scenario tomato turns every fault off and resets its counts. A fault can’t leak into the next test.

The service

orders is a small Node.js service with Postgres and a Redis cache in front of product reads. These are the lines that decide how it behaves on a bad network:

const db = new pg.Pool({
  connectionString: process.env.DATABASE_URL,
  connectionTimeoutMillis: 1000,      // a hung database costs a second, not forever
  query_timeout: 1000,
});

const cache = createClient({
  url: process.env.REDIS_URL,
  disableOfflineQueue: true,          // fail now instead of queueing while Redis is down
  socket: { connectTimeout: 500, reconnectStrategy: () => 200 },
});

// A query that fails on the connection is tried again; a timeout is not.
async function query(sql, args) {
  for (let attempt = 1; ; attempt++) {
    try {
      return await db.query(sql, args);
    } catch (e) {
      const transient = ["ECONNREFUSED", "ECONNRESET", "57P01"].includes(e.code)
        || /Connection terminated/.test(e.message);
      if (!transient || attempt === 5) throw e;
      await sleep(250 * attempt);
    }
  }
}

// The cache is an optimisation, not a dependency: 200 ms, then Postgres.
async function product(id) {
  try {
    const hit = await within(200, cache.get(`product:${id}`));
    if (hit) return JSON.parse(hit);
  } catch {}
  return (await query("SELECT id, name, price FROM products WHERE id = $1", [id])).rows[0];
}

// The order id makes a retry safe.
await query("INSERT INTO orders (id, product, qty) VALUES ($1, $2, $3) ON CONFLICT (id) DO NOTHING", …);

Any error ends in 503 {"error": "try again shortly"}.

The scenarios

Each scenario sets up one fault and checks one behaviour:

Feature: Placing orders when the database misbehaves

  Scenario: A database failover doesn't lose the order
    Given "postgres-network" resets connections for "2s"
    When "orders-client" sends "POST" to "/orders" with json:
      """
      {"id": "ord-1", "product": "tomato-seeds", "qty": 2}
      """
    Then "orders-client" response status is "201"
    And "orders-db" table "orders" has "1" rows
    And "postgres-network" has seen "4" connections

  Scenario: A hung database gets a quick answer, not a hang
    Given "postgres-network" stops responding
    When "orders-client" sends "POST" to "/orders" with json:
      """
      {"id": "ord-1", "product": "tomato-seeds", "qty": 2}
      """
    Then "orders-client" response status is "503"
    And "orders-client" response time is less than "2s"
Feature: Reading products when the cache or the database is slow

  Background:
    Given "orders-db" table "products" has values:
      | id           | name         | price |
      | tomato-seeds | Tomato seeds | 299   |

  Scenario: A cached product doesn't wait for a slow database
    Given "orders-cache" key "product:tomato-seeds" is:
      """
      {"id": "tomato-seeds", "name": "Tomato seeds", "price": 299}
      """
    And "postgres-network" adds latency "2s"
    When "orders-client" sends "GET" to "/products/tomato-seeds"
    Then "orders-client" response status is "200"
    And "orders-client" response time is less than "300ms"

  Scenario: Products are still served while the cache is down
    Given "redis-network" refuses connections
    When "orders-client" sends "GET" to "/products/tomato-seeds"
    Then "orders-client" response status is "200"
    And "orders-client" response json contains:
      """
      {"name": "Tomato seeds"}
      """

  Scenario: A hung cache costs 200ms, not the request
    Given "redis-network" stops responding
    When "orders-client" sends "GET" to "/products/tomato-seeds"
    Then "orders-client" response status is "200"
    And "orders-client" response time is less than "1s"
$ tomato run
6 scenarios (6 passed)
32 steps (32 passed)
11.2s

has seen "4" connections is how a scenario proves a retry happened. During the 2-second reset, the pool’s connection was cut and the service opened four new ones: three were reset, and the fourth, after the network healed, went through. A service that didn’t retry would have opened none.

The same suite, without the safeguards

The point of these scenarios is the bugs they catch. Here is the same suite against the same service with its timeouts, retries and fallback taken out, which is the default behaviour of most client libraries. Five of six fail:

ScenarioFaultWithout the safeguard
A database failover doesn’t lose the orderresets connections for "2s"expected status 201, got 503: one cut connection, and the order is lost
A hung database gets a quick answerstops respondingno answer within the client’s 10 s: the request hangs, and so does its caller
Products are still served while the cache is downrefuses connections on Redishangs: the Redis client queues commands while offline, so a down cache becomes a hung request
A hung cache costs 200msstops responding on Redishangs, for the same reason
An order retried after a timeout is stored oncestops responding, then is healthyhangs; and with timeouts but no duplicate protection, the retry fails (below)

The fifth row isn’t in the scenarios above, because it needs its own section.

The timeout that lied

stops responding lets queries through and swallows the replies. The first time this suite ran, the hung-database scenario also checked that no order was stored, and it failed:

table orders has 1 rows, expected 0
while the network was faulty:
  postgres-network stops responding (step 1)

The service answered 503, but the order was in the table. Step through it:

With the network hung, the app's INSERT reaches Postgres and commits, but the reply is swallowed; the app times out and answers 503, while the order is stored. A retry with the same order id must not fail or store it twice.tomatoyour apppostgres-networkpostgresapplies the faultstops respondingorders-client: POST ord-1INSERT ord-1committed ✓✕INSERT 0 1waits 1s503 try again"orders-db" table "orders" has "1" rowstomato reads it past the proxy: the 503 was wrong, the order existsheals itis healthyretries: POST ord-1INSERT … ON CONFLICT DO NOTHING201 · still 1 row

tomato applies the fault: "postgres-network" stops responding. Connections stay open and queries still go through. Only the replies stop.

The order goes in. tomato's orders-client posts ord-1, the app runs its INSERT, and Postgres commits it. Nothing about that was faulty.

The reply never comes back. The app's query timeout fires after a second, and it answers 503 try again. That's the right call: it doesn't know what happened.

Postgres knows. tomato's orders-db connects to the container directly, past the proxy, and finds the order. A 503 doesn't mean nothing happened.

tomato heals the network, then does what the 503 asked: orders-client tries again with the same order id. Because the insert ignores an id it already has, the retry gets 201 and there's still one row. Without that, every retry fails on the duplicate key, for an order that exists.

A timeout means I don’t know, not it didn’t happen. The caller was told to try again, so the honest test is to do exactly that:

  Scenario: An order retried after a timeout is stored once
    Given "postgres-network" stops responding
    And "orders-client" sends "POST" to "/orders" with json:
      """
      {"id": "ord-1", "product": "tomato-seeds", "qty": 2}
      """
    And "orders-client" response status is "503"
    When "postgres-network" is healthy
    And "orders-client" sends "POST" to "/orders" with json:
      """
      {"id": "ord-1", "product": "tomato-seeds", "qty": 2}
      """
    Then "orders-client" response status is "201"
    And "orders-db" table "orders" has "1" rows

Without ON CONFLICT (id) DO NOTHING, the retry hits the primary key, and the customer gets an error for an order that exists:

expected status 201, got 503
Body: {"error":"try again shortly"}

Without a primary key, they get two orders. Neither shows up on a healthy network.

Every fault

StepStands in forCheck that the service…
adds latency "300ms" with jitter "100ms"a slow or distant dependencystays within its budget; caches help
limits bandwidth to "64KB/s"a congested linkdoesn’t give up on a slow but healthy transfer
stops respondinga hung dependency, a partitiontimes out, and keeps retries safe
resets connectionsa crash, a failoverretries on a new connection
refuses connectionsthe dependency is downfails fast, or degrades
flaps every "2s"a restart loopbacks off, and recovers when it ends
fails the next "2" connectionsa dependency coming back upretries enough times
fails "30%" of new connectionsan unreliable linksurvives; the seed makes a failure repeatable
loses "30%" of the dataa corrupting linkerrors out instead of hanging
is healthythe fixrecovers without a restart

Each fault lasts until the scenario ends, or for "<duration>", after which it heals by itself, which is what retry code has to survive. Two steps check what the service did about it: has seen "3" connections counts the connections it opened (a retry or a reconnect is a new one), and sees a new connection within "10s" waits for a consumer or a worker to reconnect after a fault.

When a scenario fails

A step that fails while a fault is on says so, in every report format:

expected status 201, got 503
Body: {"error":"try again shortly"}
while the network was faulty:
  postgres-network resets connections (step 1, 2s left)
  postgres-network has seen 1 connection this scenario, 0 failed

tomato ui marks every step that ran on a faulty network, and the step during which a fault was applied or healed.

Limits

  • The service runs as a process (app.command). A service in a container reaches its dependencies over the Docker network, past the proxy, so tomato refuses that combination.
  • TCP, blind to the protocol. A fault hits every connection to the container, so it can’t fail only the INSERTs. For an HTTP or gRPC dependency, stub it and fail single requests there.
  • One network resource per container.

The full reference is in the network configuration docs.