On this page

Choosing Fault Levels: Start from the Bug Class

You don’t choose a mechanism - you choose a bug to close. Decide which of the six bug classes you’re testing for, and the fault level follows. This guide gives the mapping first, then the mechanics of each level.

Which bug are you closing?

Bug classLevelTypical fault
Unhandled dependency failureEither - syscall for “the whole dependency is gone” (connect=deny("ECONNREFUSED")), protocol for “this operation is refused” (error(query="INSERT*"))Start syscall-broad, refine protocol-precise
Missing timeout / runaway retryProtocol - slowness must hit one operation while the service stays healthydelay(path="/search*", delay="2s")
Non-idempotent retryProtocol - the failure must land between the side effect and the responsedelay(delay="10s") on the charge past the caller’s timeout, then assert one side effect
Partial failure / torn writesSyscall - the only level that can split a transaction mid-flightwrite=deny("EIO"), fsync=deny("EIO")
Failed recoveryEither level for the fault; the assertion is temporalscoped fault(...), then eventually(recovered)
Bad responses, not outagesProtocol only - response rewritingresponse(path="/quote", status=200, body="<garbage>")

The rest of this guide explains what each level can and cannot do, so you can refine these defaults.

Two levels, one API

# Syscall level: affects ALL writes by the db process
fault(db, write=deny("EIO"), run=scenario)

# Protocol level: affects only INSERT queries to the orders table
fault(db.pg, error(query="INSERT INTO orders*"), run=scenario)

Same fault() builtin. The first argument determines the level:

  • Service (db) → syscall level
  • Interface reference (db.pg) → protocol level

When to use syscall faults

Syscall faults simulate infrastructure failures - the kind that affect everything a service does, not just specific operations.

ScenarioFaultWhat it simulates
Server disk dieswrite=deny("EIO")Every write fails
Disk fills upwrite=deny("ENOSPC")No space for any write
Network cable unpluggedconnect=deny("ECONNREFUSED")Can’t reach anything
Network is slowconnect=delay("2s")Every connection takes 2s
Total partitionpartition(svc_a, svc_b)Bidirectional network split

Strengths:

  • Works on ANY binary - no protocol support needed
  • Catches unexpected write paths (logging, temp files, metrics)
  • Simulates real infrastructure failures accurately
  • Simple: one line tests a broad category

Weaknesses:

  • Coarse: write=deny("EIO") blocks stdout, TCP, files - everything
  • Can’t target specific queries, paths, or commands
  • May break service health (can’t respond to healthchecks under write fault)

Best for: “is the infrastructure broken?” questions.

When to use protocol faults

Protocol faults simulate application-level failures - one operation fails while the rest of the service works normally.

ScenarioFaultWhat it simulates
One SQL query failserror(query="INSERT*")DB rejects a specific insert
HTTP upstream returns 429response(path="/api/*", status=429)Rate limiting
Kafka message droppeddrop(topic="orders")Message loss on one topic
Redis SET failserror(command="SET")Write to cache fails
Slow specific endpointdelay(path="/search*", delay="2s")One endpoint is slow

Strengths:

  • Precise: target specific queries, paths, commands, topics
  • Realistic: real services fail at the query level, not the disk level
  • Service stays healthy - healthchecks and other operations work normally
  • Tests error handling for specific code paths

Weaknesses:

  • Only works for supported protocols (HTTP, Postgres, Redis, Kafka, etc.)
  • Proxy adds latency (usually <1ms, but measurable)
  • Can’t simulate low-level failures (disk corruption, kernel panics)

Best for: “does this specific operation handle errors correctly?” questions.

Decision table

QuestionLevelExample
”What if the DB server is completely down?”Syscallconnect=deny("ECONNREFUSED")
”What if this INSERT query fails?”Protocolerror(query="INSERT INTO orders*")
”What if the disk is full?”Syscallwrite=deny("ENOSPC")
”What if this HTTP endpoint returns 500?”Protocolresponse(path="/api/v1/orders", status=500)
”What if the network is slow?”Syscallconnect=delay("2s")
”What if this one Kafka topic drops messages?”Protocoldrop(topic="order-events")
”What if Redis SET fails but GET works?”Protocolerror(command="SET")
”What if two services can’t talk to each other?”Syscallpartition(api, db)
”What if the WAL fsync fails?”Syscallfsync=deny("EIO")
”What if the gRPC method returns UNAVAILABLE?”Protocolerror(method="/orders.OrderService/Create")

Combining both levels

The most thorough tests use both:

def test_degraded_system():
    """Upstream is rate-limited AND local disk is slow."""
    def scenario():
        # POST is blocked by the proxy (protocol fault).
        resp = api.post(path="/orders", body='...')
        assert_eq(resp.status, 429)

        # GET still works but DB is slow (syscall fault).
        resp = api.get(path="/orders/1")
        assert_true(resp.duration_ms > 400)
    
    def with_slow_db():
        fault(db, write=delay("500ms"), run=scenario)
    
    fault(api.http,
        response(method="POST", path="/orders*", status=429),
        run=with_slow_db,
    )

When to combine:

  • Testing graceful degradation (some operations fail, others slow)
  • Testing cascading failures (upstream error + local resource issue)
  • Testing that partial failures don’t corrupt state

Progression for a new project

Work through the bug classes in order of incident frequency:

  1. Unhandled dependency failure first - connect=deny + error() for every dependency of your critical flow. This is the class behind most production incidents, and one afternoon covers it.

  2. Timeouts and retries next - delay() on the same dependencies. Assert latency bounds, not just status codes.

  3. Then the precision classes for flows where correctness is money: non-idempotent retries (delay() past the caller’s timeout), torn writes (write=deny("EIO")), recovery (eventually() after the fault clears), bad responses (response() rewriting).

Most projects get 80% of the value from step 1 alone - which is also why “which bug class has no spec yet?” is the right review question, not “which mechanism haven’t we used?”