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, и подменить его можно без единой строчки в рабочем коде: достаточно передать клиенту другой транспорт.

Go
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 мс:

Go
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 прерывает ответ и закрывает соединение без записи в лог.

Go
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
//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)
    }
}

Обработчик, который проходит этот тест, ограничивает поход в базу собственным дедлайном и честно сообщает клиенту, когда вернуться:

Go
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. Так радиус поражения ограничен синтетическими клиентами:

YAML
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-трафик пода: задерживает, обрывает или подменяет запросы и ответы по пути и методу:

YAML
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:

  1. Задержка p99 и доля ошибок по эндпоинтам — укладываются ли в SLO.
  2. Коды ответов. Перегрузка — 503 с Retry-After, истёкший бюджет на зависимость — 504, превышение лимита — 429. Ответ 500 на сетевую неисправность зависимости обычно означает, что ошибку никто не обработал.
  3. Число горутин и память процесса (go_goroutines, go_memstats_heap_inuse_bytes). Рост без возврата после эксперимента — утечка. В тестах её ловит goleak от Uber.
  4. Заполнение пулов: db.Stats().WaitCount и WaitDuration у database/sql, число соединений у HTTP-клиента.
  5. Ретраи. Сколько попыток дошло до зависимости. Если каждый из трёх уровней делает по 3 попытки, одна неудача внизу превращается в 27 запросов — шторм ретраев, который добивает и без того больную зависимость.
  6. Возврат к норме. После снятия неисправности метрики должны вернуться к стабильному состоянию сами, без перезапуска.

7. Итог

  1. Chaos testing API проверяет не новые механизмы, а существующие: таймауты, ретраи, размыкатели, деградацию и Graceful Shutdown под управляемыми неисправностями.
  2. Гипотеза формулируется в числах из RED-метрик и SLO: задержка, доля ошибок, коды ответов, горутины, возврат к норме.
  3. Уровень 1 — тесты Go. http.RoundTripper с неисправностями и httptest с http.ErrAbortHandler проверяют дедлайны, сбросы соединений и оборванные ответы за секунды, детерминированно, на каждом коммите.
  4. Уровень 2 — Toxiproxy. Токсины latency, timeout, reset_peer, bandwidth, limit_data ломают настоящую TCP-связь с базой и соседями в интеграционных тестах.
  5. Уровень 3 — кластер. Istio VirtualService с fault и ресурсы Chaos Mesh (HTTPChaos, NetworkChaos, PodChaos) показывают каскадные эффекты всей системы. Радиус поражения, длительность и кнопка остановки обязательны.
  6. Главная находка любого хаос-теста — код без бюджета времени. Зависшая зависимость без таймаута копит горутины и соединения, пока не уронит весь API.

Мы научили API переживать отказы и проверили это экспериментами. Но как проектировать API так, чтобы другие инженеры получали удовольствие от интеграции с вашим сервисом? Каковы золотые стандарты создания технической документации, SDK и песочниц (Developer Experience)? Об этом пойдёт речь в следующей статье: 36. Документация API best practices.