35. Chaos testing API
Как проверить, что API переживает задержки, обрывы и отказы зависимостей, до того как это проверят пользователи
«Всё ломается, и ломается постоянно».
— Вернер Фогельс, CTO Amazon
Контрактные тесты из 34. Contract testing доказывают, что сервисы понимают друг друга, когда всё исправно. Юнит-тесты и httptest из 33. API testing доказывают, что обработчик верно считает. Но ни один из этих тестов не спрашивает: что сделает наш API, когда база ответит через пять секунд, сосед вернёт половину JSON, а под с сервисом убьют посреди запроса?
В продакшене такие вещи случаются не «иногда», а каждый день. И если поведение API в эти моменты никто не проверял, его поведение не определено: где-то сработает таймаут, где-то горутины будут копиться, пока процесс не съест память, а где-то клиент молча получит обрезанный ответ со статусом 200 OK.
Chaos testing API — это проверка поведения API под управляемыми неисправностями. Общую методологию хаос-инженерии (стабильное состояние, гипотеза, радиус поражения, откат) мы разобрали в 8. Chaos engineering. Здесь применим её к границе API и пройдём три уровня: от быстрых тестов на Go, которые запускаются в CI на каждый коммит, до экспериментов в кластере.
1. Что ломается на границе API
Прежде чем что-то ломать, полезно перечислить, что именно может сломаться. Для HTTP- и gRPC-API список удивительно короткий:
| Неисправность | Что видит вызывающий код | Что должен сделать наш API |
|---|---|---|
| Зависимость отвечает медленно | запрос висит | уложиться в бюджет времени (дедлайн контекста), вернуть 504 или ответ из запасного источника |
Соединение сброшено (RST) |
connection reset by peer |
повторить только идемпотентный запрос, с паузой и джиттером |
| Ответ оборван посередине | 200 OK, затем unexpected EOF при чтении тела |
считать ответ ошибкой, а не пустым результатом |
Зависимость вернула 5xx |
ошибочный статус | не усиливать отказ ретраями, разомкнуть Circuit Breaker |
| Зависимость недоступна совсем | connection refused, ошибка DNS |
быстро отказать (503 + Retry-After) или деградировать |
| Под убит во время запроса | обрыв соединения | завершиться по правилам Graceful Shutdown, не потеряв принятые запросы |
| Канал узкий или нестабильный | медленное тело, потери | не держать ресурсы бесконечно: таймауты на чтение и запись |
Правая колонка — это и есть гипотезы для экспериментов. Механизмы из неё описаны в других статьях: таймауты в 3. Timeout, ретраи в 2. Retry и backoff, размыкатель в 1. Circuit breaker, деградация в 7. Graceful degradation, идемпотентность в 13. Idempotency ключи, завершение в 10. Graceful shutdown. Хаос-тест не добавляет новых механизмов. Он проверяет, что существующие действительно срабатывают.
2. Гипотеза в терминах API
Эксперимент без гипотезы — это просто поломка. Для API гипотеза формулируется в метриках, которые мы уже собираем (RED из 31. API observability: частота запросов, ошибки, длительность):
Если Postgres отвечает на 2 секунды медленнее обычного, то
GET /orders/{id}:
- отвечает не дольше 300 мс на p99;
- возвращает
503с заголовкомRetry-Afterне более чем для 5% запросов, остальные обслуживает из кэша;- число горутин процесса не растёт больше чем вдвое;
- после снятия задержки возвращается к норме за 30 секунд.
Каждая строка — проверяемое число. Если хоть одно не выполнилось, мы нашли дефект ещё до аварии. Это и есть цель упражнения.
flowchart TD
L1["Уровень 1: тесты Go<br/>RoundTripper с неисправностями, httptest<br/>секунды, каждый коммит"]:::success
L2["Уровень 2: Toxiproxy<br/>настоящая сеть до БД и соседей<br/>минуты, интеграционные тесты"]:::process
L3["Уровень 3: кластер<br/>Istio fault injection, Chaos Mesh<br/>часы, staging и прод"]:::warning
L1 -->|"дешевле и чаще"| L2
L2 -->|"ближе к реальности"| L3
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
Чем ниже уровень, тем дешевле и быстрее тест. Чем выше, тем больше настоящих компонентов в нём участвует. Хорошая стратегия похожа на пирамиду тестирования: большинство проверок внизу, немного наверху.
3. Уровень 1: неисправности в тестах Go
Самое дешёвое место для хаоса — исходящий HTTP-клиент. Весь сетевой обмен http.Client проходит через интерфейс http.RoundTripper, и подменить его можно без единой строчки в рабочем коде: достаточно передать клиенту другой транспорт.
package faults
import (
"math/rand/v2"
"net/http"
"sync"
"time"
)
// Fault — неисправность, которую транспорт подмешивает в исходящие запросы.
type Fault struct {
Rate float64 // доля затронутых запросов: 0.1 — каждый десятый
Delay time.Duration // задержка перед отправкой запроса
Err error // сетевая ошибка вместо ответа (nil — запрос уходит дальше)
}
// Transport — http.RoundTripper, который вносит неисправность перед настоящим транспортом.
type Transport struct {
Next http.RoundTripper
Fault Fault
mu sync.Mutex
rnd *rand.Rand
}
// New создаёт транспорт с фиксированным зерном: тест с хаосом обязан быть воспроизводимым.
func New(next http.RoundTripper, f Fault, seed uint64) *Transport {
return &Transport{Next: next, Fault: f, rnd: rand.New(rand.NewPCG(seed, seed))}
}
func (t *Transport) hit() bool {
t.mu.Lock()
defer t.mu.Unlock()
return t.rnd.Float64() < t.Fault.Rate
}
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
if !t.hit() {
return t.Next.RoundTrip(req)
}
if t.Fault.Delay > 0 {
timer := time.NewTimer(t.Fault.Delay)
defer timer.Stop()
select {
case <-timer.C:
case <-req.Context().Done():
return nil, req.Context().Err() // задержка честно уважает дедлайн запроса
}
}
if t.Fault.Err != nil {
return nil, t.Fault.Err
}
return t.Next.RoundTrip(req)
}
Три детали делают этот транспорт пригодным для CI. Генератор случайных чисел (math/rand/v2, Go 1.22+) создаётся с фиксированным зерном: тест, который падает «иногда», хуже, чем никакого теста. Генератор защищён мьютексом, потому что RoundTrip вызывается из многих горутин одновременно. А задержка слушает контекст запроса, иначе мы проверяли бы не таймауты клиента, а терпение time.Sleep.
Теперь проверим код, который ходит в сервис цен с бюджетом в 300 мс:
func fetchPrice(ctx context.Context, c *http.Client, url string) error {
ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := c.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
_, err = io.ReadAll(resp.Body)
return err
}
func TestFetchPriceRespectsDeadline(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`{"price":42}`))
}))
defer srv.Close()
// Каждый запрос задерживается на 2 секунды: зависимость «подвисла».
client := &http.Client{Transport: New(http.DefaultTransport, Fault{Rate: 1, Delay: 2 * time.Second}, 1)}
start := time.Now()
err := fetchPrice(context.Background(), client, srv.URL)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("ждали DeadlineExceeded, получили %v", err)
}
if took := time.Since(start); took > 500*time.Millisecond {
t.Fatalf("бюджет 300 мс не соблюдён: %v", took)
}
}
func TestFetchPriceSurfacesReset(t *testing.T) {
client := &http.Client{Transport: New(http.DefaultTransport, Fault{Rate: 1, Err: syscall.ECONNRESET}, 1)}
err := fetchPrice(context.Background(), client, "http://prices.invalid/")
if !errors.Is(err, syscall.ECONNRESET) {
t.Fatalf("ждали ECONNRESET, получили %v", err)
}
}
Тест проходит за 300 мс, а не за 2 секунды, и доказывает ровно одну вещь: бюджет времени соблюдается. Если кто-то однажды заменит контекст запроса на context.Background(), тест упадёт сразу, а не через полгода на продакшене.
Отдельно проверим неисправность, о которой обычно забывают: ответ, оборванный посередине. Статус 200 OK уже пришёл, а тело так и не дочиталось. В httptest такой сервер пишется в несколько строк: стандартная паника http.ErrAbortHandler прерывает ответ и закрывает соединение без записи в лог.
func TestTruncatedBodyIsAnError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Length", "1000")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"items":[`))
w.(http.Flusher).Flush()
panic(http.ErrAbortHandler) // сервер «умер» посреди ответа
}))
defer srv.Close()
err := fetchPrice(context.Background(), srv.Client(), srv.URL)
if !errors.Is(err, io.ErrUnexpectedEOF) {
t.Fatalf("ждали io.ErrUnexpectedEOF, получили %v", err)
}
}
Код, который проверяет только resp.StatusCode, на этом тесте обнаруживает, что пустой список товаров и оборванный список товаров для него одно и то же.
4. Уровень 2: Toxiproxy — настоящая сеть под контролем
Транспорт из прошлого раздела ломает только HTTP-клиент. А драйвер Postgres, клиент Redis и gRPC ходят в сеть своими путями. Для них нужен инструмент, который ломает саму TCP-связь.
Toxiproxy (написан на Go в Shopify) — это TCP-прокси, который встаёт между сервисом и зависимостью и по команде через HTTP API (порт 8474) подмешивает в трафик токсины:
| Токсин | Что делает | Ключевые атрибуты |
|---|---|---|
latency |
задерживает данные | latency, jitter (мс) |
bandwidth |
ограничивает скорость | rate (КБ/с) |
timeout |
перестаёт пропускать данные и закрывает соединение через заданное время (0 — никогда) |
timeout (мс) |
reset_peer |
обрывает соединение RST («connection reset by peer») |
timeout (мс) |
slow_close |
задерживает закрытие соединения | delay (мс) |
slicer |
режет поток на мелкие пакеты с паузами | average_size, size_variation, delay (мкс) |
limit_data |
закрывает соединение после N байт | bytes |
Полный отказ зависимости — не токсин, а выключенный прокси (proxy.Disable()). Каждый токсин действует в одном направлении: downstream (от зависимости к нам) или upstream (от нас к ней), а атрибут toxicity задаёт долю затронутых соединений.
sequenceDiagram
autonumber
participant T as Тест Go
participant TP as Toxiproxy, API на порту 8474
participant API as Наш API
participant DB as Postgres
T->>TP: CreateProxy postgres, порт 25432 → postgres 5432
T->>TP: AddToxic latency 2000 мс (downstream)
T->>API: GET /orders/42
API->>TP: запрос к БД через порт 25432
TP->>DB: запрос без изменений
DB-->>TP: ответ
Note over TP: держит ответ 2 секунды
API-->>T: 503 + Retry-After через 200 мс
T->>TP: RemoveToxic, Delete
Интеграционный тест с Toxiproxy выглядит так. Сервер Toxiproxy запущен рядом, например docker run -p 8474:8474 -p 25432:25432 ghcr.io/shopify/toxiproxy в одной Docker-сети с Postgres (или через Testcontainers, как в 33. API testing). Сервис подключается к базе не напрямую, а через порт прокси:
//go:build integration
package orders
import (
"database/sql"
"net/http"
"net/http/httptest"
"testing"
"time"
toxiproxy "github.com/Shopify/toxiproxy/v2/client"
_ "github.com/jackc/pgx/v5/stdlib" // драйвер "pgx" для database/sql
)
func TestOrdersAPIUnderSlowDatabase(t *testing.T) {
tp := toxiproxy.NewClient("localhost:8474")
proxy, err := tp.CreateProxy("postgres", "0.0.0.0:25432", "postgres:5432")
if err != nil {
t.Fatal(err)
}
defer proxy.Delete()
db, err := sql.Open("pgx", "postgres://app:app@localhost:25432/orders")
if err != nil {
t.Fatal(err)
}
defer db.Close()
srv := httptest.NewServer((&API{DB: db}).Handler())
defer srv.Close()
// База отвечает на 2 секунды дольше: ответы вниз по течению (downstream) задерживаются.
if _, err := proxy.AddToxic("slow_db", "latency", "downstream", 1.0,
toxiproxy.Attributes{"latency": 2000, "jitter": 100}); err != nil {
t.Fatal(err)
}
defer proxy.RemoveToxic("slow_db")
start := time.Now()
resp, err := http.Get(srv.URL)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusServiceUnavailable || resp.Header.Get("Retry-After") == "" {
t.Fatalf("ждали 503 с Retry-After, получили %d", resp.StatusCode)
}
if took := time.Since(start); took > 400*time.Millisecond {
t.Fatalf("API ждало базу %v вместо бюджета 200 мс", took)
}
}
Обработчик, который проходит этот тест, ограничивает поход в базу собственным дедлайном и честно сообщает клиенту, когда вернуться:
func (a *API) get(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond)
defer cancel()
if err := a.DB.PingContext(ctx); err != nil {
w.Header().Set("Retry-After", "1")
http.Error(w, "database unavailable", http.StatusServiceUnavailable)
return
}
w.Write([]byte(`{"status":"ok"}`))
}
Тег сборки integration отделяет такие тесты от быстрых: go test ./... их не трогает, а go test -tags integration ./... запускается там, где поднят Docker. Тот же приём с токсином reset_peer проверяет ретраи, bandwidth — таймауты на чтение тела, а limit_data — обработку оборванных ответов уже не в HTTP-клиенте, а в драйвере базы.
5. Уровень 3: эксперименты в кластере
Тесты на Go проверяют один сервис. Но самые неприятные отказы возникают из сочетаний: балансировщик, ретраи на трёх уровнях, автоскейлер и очередь. Их можно увидеть только в системе целиком, поэтому третий уровень — эксперименты в Kubernetes, сначала на staging, затем на проде с ограниченным радиусом поражения.
Istio: неисправности на уровне service mesh
Если сервисы общаются через sidecar-прокси Envoy, неисправность можно внести без изменения кода — правилом VirtualService. Правило ниже задерживает на 3 секунды запросы к сервису платежей, но только запросы с заголовком x-chaos: on. Так радиус поражения ограничен синтетическими клиентами:
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata:
name: payments
spec:
hosts:
- payments
http:
- match:
- headers:
x-chaos:
exact: "on"
fault:
delay:
percentage:
value: 100
fixedDelay: 3s
route:
- destination:
host: payments
- route:
- destination:
host: payments
Вместо delay можно указать abort с httpStatus: 503 — тогда Envoy сам ответит ошибкой, и мы увидим, размыкается ли Circuit Breaker у вызывающей стороны.
Chaos Mesh: HTTP, сеть и поды
Chaos Mesh — проект CNCF, который описывает эксперименты как ресурсы Kubernetes. Для API особенно полезны три вида.
HTTPChaos вмешивается в HTTP-трафик пода: задерживает, обрывает или подменяет запросы и ответы по пути и методу:
apiVersion: chaos-mesh.org/v1alpha1
kind: HTTPChaos
metadata:
name: orders-api-slow
spec:
mode: all
selector:
labelSelectors:
app: orders-api
target: Request
port: 8080
method: GET
path: /api/v1/orders/*
delay: 2s
duration: 5m
NetworkChaos работает уровнем ниже, в сетевом пространстве пода: задержки, потери и дублирование пакетов (через tc netem) и сетевые разделы между группами подов (через iptables). PodChaos с действием pod-kill убивает поды по расписанию. Это лучшая проверка Graceful Shutdown из 10. Graceful shutdown: под нагрузкой (например, генератором vegeta или k6) убиваем по одному поду и смотрим, появились ли у клиентов ошибки 502 и оборванные запросы. Правильный ответ — ноль.
6. Что смотреть во время эксперимента
Хаос-тест считается пройденным не тогда, когда сервис выжил, а когда все числа гипотезы остались в границах. Минимальный список наблюдений для API:
- Задержка p99 и доля ошибок по эндпоинтам — укладываются ли в SLO.
- Коды ответов. Перегрузка —
503сRetry-After, истёкший бюджет на зависимость —504, превышение лимита —429. Ответ500на сетевую неисправность зависимости обычно означает, что ошибку никто не обработал. - Число горутин и память процесса (
go_goroutines,go_memstats_heap_inuse_bytes). Рост без возврата после эксперимента — утечка. В тестах её ловитgoleakот Uber. - Заполнение пулов:
db.Stats().WaitCountиWaitDurationуdatabase/sql, число соединений у HTTP-клиента. - Ретраи. Сколько попыток дошло до зависимости. Если каждый из трёх уровней делает по 3 попытки, одна неудача внизу превращается в 27 запросов — шторм ретраев, который добивает и без того больную зависимость.
- Возврат к норме. После снятия неисправности метрики должны вернуться к стабильному состоянию сами, без перезапуска.
7. Итог
- Chaos testing API проверяет не новые механизмы, а существующие: таймауты, ретраи, размыкатели, деградацию и Graceful Shutdown под управляемыми неисправностями.
- Гипотеза формулируется в числах из RED-метрик и SLO: задержка, доля ошибок, коды ответов, горутины, возврат к норме.
- Уровень 1 — тесты Go.
http.RoundTripperс неисправностями иhttptestсhttp.ErrAbortHandlerпроверяют дедлайны, сбросы соединений и оборванные ответы за секунды, детерминированно, на каждом коммите. - Уровень 2 — Toxiproxy. Токсины
latency,timeout,reset_peer,bandwidth,limit_dataломают настоящую TCP-связь с базой и соседями в интеграционных тестах. - Уровень 3 — кластер. Istio
VirtualServiceсfaultи ресурсы Chaos Mesh (HTTPChaos,NetworkChaos,PodChaos) показывают каскадные эффекты всей системы. Радиус поражения, длительность и кнопка остановки обязательны. - Главная находка любого хаос-теста — код без бюджета времени. Зависшая зависимость без таймаута копит горутины и соединения, пока не уронит весь API.
Мы научили API переживать отказы и проверили это экспериментами. Но как проектировать API так, чтобы другие инженеры получали удовольствие от интеграции с вашим сервисом? Каковы золотые стандарты создания технической документации, SDK и песочниц (Developer Experience)? Об этом пойдёт речь в следующей статье: 36. Документация API best practices.