faulttest

package
v0.7.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 4, 2026 License: MIT Imports: 19 Imported by: 0

Documentation

Overview

Package faulttest injects deterministic faults into Gombit's own tests.

A fault is expressed as "fail exactly the second call", never as a probability: an Injector walks a fixed list of steps, one per call, and every call it sees is counted, so a test can reproduce a failure path on every run and assert what the code under test did about it.

inj := faulttest.FailOnCall(2, errConnLost) // the 2nd call fails
err := inj.Do(ctx, func(ctx context.Context) error { return dep.Call(ctx) })

Injectors wrap a dependency at a narrow seam: Do around a function, or the database/sql wrapper in this package (OpenDB, WrapConnector) for GORM and transactions. Nothing here is global and nothing changes production code paths: a test opts in by building the wrapped dependency itself.

A test that sets up over the same wrapped dependency (migrations, fixtures) disarms the injector first and arms it for the calls under test.

Delays and blocks wait on timers, channels, and the call's context, never on sleeps the test has to guess at: a test that needs a call to be in flight waits on Reached(n), and releases it by closing the channel it handed to BlockUntil (or Block).

Index

Constants

This section is empty.

Variables

View Source
var ErrInjected = errors.New("faulttest: injected fault")

ErrInjected is a ready-made fault for tests that do not need a specific error.

Functions

func CheckRetryPolicy

func CheckRetryPolicy(t testing.TB, c RetryContract)

CheckRetryPolicy runs c.Do through the retry contract and reports every violated property (by name) through t.Errorf:

  • bounded: an op that always fails retryably is tried no more than MaxAttempts times;
  • retryable: ...and no fewer (a retryable failure is retried while the budget lasts), and an op that fails once and then succeeds succeeds on the retry;
  • cancellation: a context that has already ended makes Do return the context's error without starting an attempt; a context canceled mid-backoff ends Do promptly with the context's error, and no further attempt runs;
  • permanent: a non-retryable error is not retried;
  • backoff: the waits asked for follow Delay (exactly, when Deterministic) and lie within [0, MaxDelay]; and Delay itself, called 20 times per attempt (for jitter) at every attempt 1..max(MaxAttempts, 128) and at 1000, is within [0, MaxDelay]. That catches a shift or doubling that overflows: any base of 1ns or more wraps by attempt 64. It does not catch an attempt multiplied by a unit, which wraps at an attempt that depends on the unit, usually far past 1000: compute a delay so it cannot overflow (cap before the arithmetic, not after);
  • success: a success ends the retries at once;
  • observable failure: the final error wraps the last attempt's error (and reports the attempt count, when Attempts is set).

Each run of the policy is bounded by a guard: a policy that never returns fails the check, and its goroutine is left running for the rest of the test binary (Go cannot stop it), so a broken policy should be fixed, not kept failing. The contract describes attempt-bounded policies with a Delay per attempt; a policy bounded by a total deadline states the attempts that deadline allows.

func ForEachDB

func ForEachDB(t *testing.T, dbs []TestDB, fn func(t *testing.T, kind database.Driver, dsn string))

ForEachDB runs fn as a subtest per database, skipping those without a DSN.

func Idle

func Idle(t testing.TB, db *database.DB)

Idle waits until no connection of db is in use, and fails t if one stays in use: a transaction a failure left open (never committed or rolled back) holds its connection, and row counts on another connection cannot see it. database/sql releases a context-canceled transaction's connection on its own goroutine, so this waits (up to 5s) rather than sampling once.

func Inserts

func Inserts(table string) func(query string) bool

Inserts matches INSERT statements into table, however the dialect quotes it (a DBFaults.Match).

func OpenDB

func OpenDB(kind database.Driver, dsn string, faults *DBFaults) (*database.DB, error)

OpenDB opens kind's database at dsn through a connection wrapped with faults, as a *database.DB (database.OpenConn): code under test gets the same GORM setup as from database.Open. Opening is not part of any fault sequence: every injector is suspended while GORM opens (its ping, a dialector's version query), then restored to how it was armed and reset, and the connections the open dialed are closed. So call 1 is the test's own first call on every driver: Statement FailOnce fails the test's first statement, and a Connect fault meets the test's first dial. The pool gets database.Open's defaults for the driver (ConfigurePool). Close the DB when done.

func PostgresDSNVia

func PostgresDSNVia(dsn, addr string) (string, error)

PostgresDSNVia points a postgres:// URL DSN at addr, keeping everything else.

func PostgresHostPort

func PostgresHostPort(dsn string) (string, error)

PostgresHostPort returns a postgres:// URL DSN's host:port: the upstream to proxy.

func WrapConnector

func WrapConnector(c driver.Connector, faults *DBFaults) driver.Connector

WrapConnector returns a connector whose connections inject faults (see DBFaults). Open it with sql.OpenDB.

Types

type BodyTracker

type BodyTracker struct {
	// contains filtered or unexported fields
}

BodyTracker is an http.RoundTripper that counts response bodies opened and not yet closed, to prove a client closes every body, failures included.

func TrackBodies

func TrackBodies(base http.RoundTripper) *BodyTracker

TrackBodies wraps base (http.DefaultTransport when nil).

func (*BodyTracker) Open

func (b *BodyTracker) Open() int

Open reports the bodies returned and not closed.

func (*BodyTracker) RoundTrip

func (b *BodyTracker) RoundTrip(r *http.Request) (*http.Response, error)

RoundTrip implements http.RoundTripper.

type DBFaults

type DBFaults struct {
	// Connect is hit when the pool opens a new connection.
	Connect *Injector
	// Begin is hit when a transaction starts.
	Begin *Injector
	// Statement is hit by every Exec and Query, inside a transaction or not,
	// before the statement reaches the database: a failed statement has no
	// effect. With Match set, only matching statements are hit (and counted).
	Statement *Injector
	// Match limits Statement to the statements it reports true for (nil:
	// all). Dialects quote identifiers differently, so match on the table
	// name rather than a quoted form.
	Match func(query string) bool
	// Commit is hit before a transaction commits. A failed commit rolls the
	// transaction back and returns the injected error: nothing it did
	// persists, and its connection goes back to the pool clean. database/sql
	// gives Commit and Rollback no context, so a Delay or BlockUntil here
	// ends only by its timer or channel, never by the caller's context.
	Commit *Injector
	// Rollback is hit before a transaction rolls back. The rollback still
	// happens (the connection must not stay inside the transaction); the
	// injected error is what the caller sees. When the real rollback fails
	// too (after either fault), the error also carries it and
	// driver.ErrBadConn, so the pool discards the connection.
	Rollback *Injector
}

DBFaults are the faults a wrapped database/sql driver injects, one Injector per boundary. A nil Injector never fails. Set them before the connection is opened; an Injector's own state (counts, Reset) can be used at any time.

func (*DBFaults) Arm

func (f *DBFaults) Arm()

Arm arms every injector: the next call at each boundary is its call 1.

func (*DBFaults) Disarm

func (f *DBFaults) Disarm()

Disarm disarms every injector (for setup over the wrapped database).

func (*DBFaults) Injectors

func (f *DBFaults) Injectors() []*Injector

Injectors are the boundary injectors, nil ones included.

type FakeSleeper

type FakeSleeper struct {
	// Blocking makes Sleep wait until ctx ends.
	Blocking bool
	// contains filtered or unexported fields
}

FakeSleeper records every delay it is asked for and returns at once (a context that has already ended still returns its error). With Blocking set, it instead waits for the context to end: a retry that is mid-backoff when it is canceled.

func (*FakeSleeper) Delays

func (s *FakeSleeper) Delays() []time.Duration

Delays returns the delays asked for, in order.

func (*FakeSleeper) Sleep

func (s *FakeSleeper) Sleep(ctx context.Context, d time.Duration) error

Sleep implements Sleeper.

func (*FakeSleeper) Sleeping

func (s *FakeSleeper) Sleeping() <-chan struct{}

Sleeping returns a channel closed once the first Sleep has begun.

type HTTPDependency

type HTTPDependency struct {
	// contains filtered or unexported fields
}

HTTPDependency is a loopback HTTP server standing in for a dependency. Each call gets the next scripted step; calls after the script get the fallback (200 with an empty JSON object unless set with Otherwise). It counts calls, reports when a call arrives (Reached), and when a handler returns (InFlight), so a test can prove none is left running.

func NewHTTPDependency

func NewHTTPDependency(t testing.TB, steps ...HTTPStep) *HTTPDependency

NewHTTPDependency starts a dependency answering with steps, one per call, and closes it when t ends.

func (*HTTPDependency) Abandoned

func (d *HTTPDependency) Abandoned() int

Abandoned reports how many calls the client gave up on (canceled, timed out, disconnected) before they were answered; calls ended by Close do not count.

func (*HTTPDependency) Calls

func (d *HTTPDependency) Calls() int

Calls reports how many calls reached the dependency.

func (*HTTPDependency) Client

func (d *HTTPDependency) Client() *http.Client

Client is an HTTP client for the dependency (loopback only).

func (*HTTPDependency) Close

func (d *HTTPDependency) Close()

Close releases any hanging call and shuts the dependency down, waiting for its handlers to return.

func (*HTTPDependency) InFlight

func (d *HTTPDependency) InFlight() int

InFlight reports how many calls are being answered now.

func (*HTTPDependency) Otherwise

func (d *HTTPDependency) Otherwise(step HTTPStep) *HTTPDependency

Otherwise sets the answer for calls past the script.

func (*HTTPDependency) Reached

func (d *HTTPDependency) Reached(n int) <-chan struct{}

Reached returns a channel closed once call n has arrived.

func (*HTTPDependency) URL

func (d *HTTPDependency) URL() string

URL is the dependency's base URL.

func (*HTTPDependency) WaitIdle

func (d *HTTPDependency) WaitIdle(t testing.TB)

WaitIdle waits until no call is being answered (the server notices a client that went away on its own goroutine), and fails t if one still is after 5s: a handler left running is a leak.

type HTTPStep

type HTTPStep struct {
	// contains filtered or unexported fields
}

HTTPStep is how the dependency answers one call.

func CutBody

func CutBody(partial string) HTTPStep

CutBody sends the status line, headers promising a longer body, and partial of it, then closes the connection: the client's read fails mid-body.

func Hang

func Hang(release <-chan struct{}) HTTPStep

Hang holds the call without answering until release is closed (then answers 200 with an empty JSON object), the client goes away, or the dependency is closed: a dependency slower than any deadline, driven by a channel rather than a sleep.

func ResetConnection

func ResetConnection() HTTPStep

ResetConnection closes the connection with a TCP reset before answering: the client sees "connection reset by peer" (or an EOF), never a response.

func Respond

func Respond(status int, body string) HTTPStep

Respond answers with status and body (Content-Type application/json).

func ServerError

func ServerError() HTTPStep

ServerError answers 500.

func TooManyRequests

func TooManyRequests(retryAfter time.Duration) HTTPStep

TooManyRequests answers 429 with Retry-After, which HTTP gives in whole seconds: retryAfter is rounded up, so a sub-second wait is 1, never 0 ("retry now").

func (HTTPStep) String

func (s HTTPStep) String() string

type Injector

type Injector struct {
	// contains filtered or unexported fields
}

Injector decides, call by call, whether a call fails. It is safe for concurrent use: calls are numbered in the order they reach it. A nil *Injector lets every call through, so an optional fault can be left unset.

func BlockUntil

func BlockUntil(release <-chan struct{}) *Injector

BlockUntil holds every call until release is closed (see Block).

func Delay

func Delay(d time.Duration) *Injector

Delay holds every call for d (see Wait).

func FailAlways

func FailAlways(err error) *Injector

FailAlways fails every call with err.

func FailNTimes

func FailNTimes(n int, err error) *Injector

FailNTimes fails the first n calls with err and lets the rest through.

func FailOnCall

func FailOnCall(n int, err error) *Injector

FailOnCall fails only the nth call (1-based) with err.

func FailOnce

func FailOnce(err error) *Injector

FailOnce fails the first call with err and lets the rest through.

func Sequence

func Sequence(steps ...Step) *Injector

Sequence applies steps to the first calls, one each, and lets every call after them through.

func SequenceThen

func SequenceThen(rest Step, steps ...Step) *Injector

SequenceThen applies steps to the first calls, one each, and rest to every call after them (Sequence is SequenceThen(Success(), ...)).

func (*Injector) Arm

func (i *Injector) Arm()

Arm ends Disarm and starts over (Reset): the next call is call 1.

func (*Injector) Calls

func (i *Injector) Calls() int

Calls reports how many calls reached the injector.

func (*Injector) Disarm

func (i *Injector) Disarm() *Injector

Disarm lets every call through without counting it, until Arm: a test disarms an injector while it sets up (migrations, fixtures, a login) over the same wrapped dependency, so only the calls under test are numbered.

func (*Injector) Do

func (i *Injector) Do(ctx context.Context, fn func(context.Context) error) error

Do runs fn unless the call fails: Hit, then fn.

func (*Injector) Failures

func (i *Injector) Failures() int

Failures reports how many calls it failed (a context that ended during a wait or block included).

func (*Injector) Hit

func (i *Injector) Hit(ctx context.Context) error

Hit counts a call and applies its step: nil to let it through, or the error it fails with.

func (*Injector) Reached

func (i *Injector) Reached(n int) <-chan struct{}

Reached returns a channel closed once call n (1-based) has reached the injector, before its step runs: a test waits on it to know a blocked call is in flight.

func (*Injector) Reset

func (i *Injector) Reset()

Reset starts the injector over: counts go to zero and the next call is call 1 again. A call still in flight from before (a blocked one, say) finishes as it would have, but no longer counts. Channels from Reached for calls not yet reached stay pending, numbered from the new start.

type RealSleeper

type RealSleeper struct{}

RealSleeper waits on a timer.

func (RealSleeper) Sleep

func (RealSleeper) Sleep(ctx context.Context, d time.Duration) error

Sleep implements Sleeper.

type RetryContract

type RetryContract struct {
	// Do runs op under the policy, waiting between attempts through
	// sleeper. It is the policy under test.
	Do func(ctx context.Context, sleeper Sleeper, op func(context.Context) error) error
	// Retryable is an error the policy retries; Permanent one it must not.
	Retryable, Permanent error
	// MaxAttempts is the policy's attempt budget, the first attempt
	// included: an op that keeps failing retryably runs exactly MaxAttempts
	// times (never more, which is the bound; never fewer, since a retryable
	// failure is retried while budget remains).
	MaxAttempts int
	// Delay is the policy's backoff: the wait after the attempt-th failure
	// (1-based). Every delay it produces must lie within [0, MaxDelay] (no
	// negative or overflowed durations, jitter included). The checker samples
	// that (see CheckRetryPolicy's backoff bullet, which says what the sample
	// does not catch): cap a delay before the arithmetic can overflow, not
	// after.
	Delay func(attempt int) time.Duration
	// MaxDelay caps Delay.
	MaxDelay time.Duration
	// Deterministic says Delay is exact (no jitter): the waits the policy
	// asks the sleeper for must equal Delay(1), Delay(2), ...
	Deterministic bool
	// Attempts, when set, reads the attempt count from the policy's final
	// error (a diagnostic the policy promises to expose).
	Attempts func(err error) (int, bool)
}

RetryContract describes a retry policy for CheckRetryPolicy (INV-4: every retry is bounded, honors cancellation, and ends in success or an observable terminal error).

type Sleeper

type Sleeper interface {
	// Sleep waits for d, or until ctx ends (returning ctx's error).
	Sleep(ctx context.Context, d time.Duration) error
}

Sleeper is the time seam a retry policy waits through between attempts, so its backoff can be tested without sleeping.

type Step

type Step struct {
	// contains filtered or unexported fields
}

Step is what one call does: succeed, fail with an error, wait, or block.

func Block

func Block(release <-chan struct{}) Step

Block holds the call until release is closed, then lets it through. A context that ends first fails the call with the context's error.

func BlockThenFail

func BlockThenFail(release <-chan struct{}, err error) Step

BlockThenFail holds the call until release is closed, then fails it with err: a call that stalls, and then its dependency gives up (the second writer waits on the first's lock, which then rolls back). A context that ends first fails the call with the context's error.

func Failure

func Failure(err error) Step

Failure fails the call with err.

func Success

func Success() Step

Success lets the call through.

func Wait

func Wait(d time.Duration) Step

Wait holds the call for d, then lets it through. A context that ends first fails the call with the context's error.

type TCPProxy

type TCPProxy struct {
	// contains filtered or unexported fields
}

TCPProxy sits between the code under test and a real dependency (a test database, a test Redis) on loopback and faults the transport on command: a test toggles each fault at an explicit point, so the scenario is the same on every run. It is in-process: no proxy service to run, and it can only reach the upstream the test names.

proxy := faulttest.NewTCPProxy(t, "127.0.0.1:5432")
dsn := faulttest.PostgresDSNVia(dsn, proxy.Addr())
proxy.Hold()   // the dependency stops answering (latency past any deadline)
proxy.Cut()    // every open connection drops
proxy.Refuse() // new connections are refused
proxy.Heal()   // all of it lifted: the next operation must succeed

func NewTCPProxy

func NewTCPProxy(t testing.TB, upstream string) *TCPProxy

NewTCPProxy starts a proxy to upstream on a loopback port, and closes it when t ends.

func (*TCPProxy) Addr

func (p *TCPProxy) Addr() string

Addr is the proxy's address: point the code under test here instead of at the upstream.

func (*TCPProxy) Close

func (p *TCPProxy) Close()

Close stops the proxy and drops every connection.

func (*TCPProxy) Connections

func (p *TCPProxy) Connections() int

Connections reports how many connections the proxy has accepted.

func (*TCPProxy) Cut

func (p *TCPProxy) Cut()

Cut closes every open connection, both sides (a connection lost mid-query).

func (*TCPProxy) Heal

func (p *TCPProxy) Heal() error

Heal lifts every fault: held traffic flows, and a refusing proxy listens again on the same address. Bytes held on a connection that is still open are delivered, as a recovering network would, even if their caller gave up meanwhile; those on a connection that closed are dropped with it. Connections that were cut stay cut; the next operation has to reconnect, which is the recovery a test asserts.

func (*TCPProxy) Held

func (p *TCPProxy) Held() <-chan struct{}

Held returns a channel closed once traffic is waiting on the current Hold: the proxy has the call's bytes and has not forwarded them (the dependency has not seen the call yet).

func (*TCPProxy) Hold

func (p *TCPProxy) Hold()

Hold stops forwarding in both directions: connections stay open, and every byte waits until Heal. To the client the dependency is slower than any deadline.

func (*TCPProxy) Refuse

func (p *TCPProxy) Refuse()

Refuse stops accepting: a new connection is refused at once, as by a dependency that is down. Open connections are left alone (Cut them too for a full outage).

func (*TCPProxy) Reset

func (p *TCPProxy) Reset()

Reset closes every open connection with a TCP reset.

type TestDB

type TestDB struct {
	Name string
	Kind database.Driver
	// DSN returns the database to use; "" skips it.
	DSN func(t *testing.T) string
}

TestDB is a database a fault test runs against: SQLite always, PostgreSQL and MySQL when a package's integration flags name one.

func SQLiteDB

func SQLiteDB() TestDB

SQLiteDB is a fresh SQLite file per test.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL