Chaos testing
This guide checks that a client retries a failing server, survives a reset connection, and gives up on a slow one. It uses sequences, faults and priorities.
The client
get retries a GET up to three times, on a 5xx answer or a network error:
// get retries a GET up to three times on a 5xx answer or a network error.
func get(ctx context.Context, client *http.Client, url string) ([]byte, error) {
var last error
for range 3 {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
res, err := client.Do(req)
if err != nil {
last = err
continue
}
body, err := io.ReadAll(res.Body)
res.Body.Close()
if err != nil {
last = err
continue
}
if res.StatusCode >= 500 {
last = fmt.Errorf("status %d", res.StatusCode)
continue
}
return body, nil
}
return nil, last
}A server that recovers
Seq answers each request with the next reply, then the one of OnSequenceEnded: two 503s, then the report. AssertNumberOfCalls checks the client called three times.
func TestRetriesUntilTheServerRecovers(t *testing.T) {
m := mockhttp.MustNew(mockhttp.Setup().Silent())
m.MustStart()
t.Cleanup(m.Close)
scoped := m.MustMock(mockhttp.Getf("/report").Reply(mockhttp.Seq(
mockhttp.ServiceUnavailable(),
mockhttp.ServiceUnavailable(),
).OnSequenceEnded(mockhttp.OK().PlainText("done"))))
body, err := get(t.Context(), http.DefaultClient, m.URL("/report"))
if err != nil {
t.Fatal(err)
}
if string(body) != "done" {
t.Errorf("body = %q", body)
}
scoped.AssertNumberOfCalls(t, 3)
}A reset connection
fault.ConnectionResetByPeer() resets the TCP connection before the status line. Once() serves it to the first request only; the next request falls through to the mock of priority 1, which answers.
func TestRetriesAResetConnection(t *testing.T) {
m := mockhttp.MustNew(mockhttp.Setup().Silent())
m.MustStart()
t.Cleanup(m.Close)
m.MustMock(
mockhttp.Getf("/report").Once().WirePipe(fault.ConnectionResetByPeer()).Reply(mockhttp.OK()),
mockhttp.Getf("/report").Priority(1).Reply(mockhttp.OK().PlainText("done")),
)
body, err := get(t.Context(), http.DefaultClient, m.URL("/report"))
if err != nil {
t.Fatal(err)
}
if string(body) != "done" {
t.Errorf("body = %q", body)
}
}Wire faults, such as resets, need HTTP/1.x: Go's client uses it against a plain http:// server.
A slow server
fault.Latency delays every write of the answer. The client gives up after its timeout, and the test checks the error is a timeout:
func TestGivesUpOnASlowServer(t *testing.T) {
m := mockhttp.MustNew(mockhttp.Setup().Silent())
m.MustStart()
t.Cleanup(m.Close)
m.MustMock(mockhttp.Getf("/report").
WirePipe(fault.Latency(500*time.Millisecond, 0)).
Reply(mockhttp.OK().PlainText("too late")))
client := &http.Client{Timeout: 100 * time.Millisecond}
_, err := get(t.Context(), client, m.URL("/report"))
if timeout, ok := errors.AsType[net.Error](err); !ok || !timeout.Timeout() {
t.Fatalf("err = %v, want a timeout", err)
}
}To slow only the body, use Pipe in place of WirePipe: the client then gets the status and headers at once. fault.Bandwidth, fault.ChunkedDribbleDelay and fault.Timeout shape a slow network in other ways: see Faults.
The same in mock files
request: {method: GET, path: /report}
response: {status: 200, body: done}
wire_pipes:
- type: latency
latency: 500msWhat you used
Seq,OnSequenceEnded: Replies.WirePipe,fault.ConnectionResetByPeer,fault.Latency: Pipes, Faults.Once,Priority,AssertNumberOfCalls: Mock lifecycle, Mocks.