Дайджесты новостей
Предметный натюрморт с дуэтом аналоговых счетчиков и ключом транзакции, иллюстрирующий идемпотентность сетевых запросов в Go.

go-retryablehttp: почему повторная отправка тела не гарантирует безопасность операции

Каждый бэкенд-разработчик на Go рано или поздно сталкивается с капризами распределенных сетей: кратковременный разрыв TCP-соединения, таймаут пограничного прокси или перезапуск пода в Kubernetes. В стандартном клиенте net/http повторить упавший запрос не так-то просто: поток данных req.Body закрывается и вычитывается до конца при первой же попытке, и повторный вызов падает с ошибкой http: Request.Body already consumed.

Чтобы не писать велосипеды с буферизацией байтов, сообщество обычно берет проверенную библиотеку go-retryablehttp от компании HashiCorp. Она бережно оборачивает стандартный транспорт, умеет перематывать io.ReadSeeker и автоматически повторяет отправку при кодах 500, 502, 503 и 504.

Однако здесь кроется опаснейшая иллюзия: транспортная повторяемость (transport retryability) не имеет ничего общего с прикладной идемпотентностью (application idempotence).

Ловушка параметра RetryMax и обрыва ответа

Первая неочевидная грабля библиотеки кроется в арифметике попыток. Когда ты задаешь в конфигурации параметр retryClient.RetryMax = 2, легко подумать, что сервис выполнит ровно два запроса. На самом деле параметр означает количество повторов после первой неудачной отправки. То есть клиент сделает до трех запросов: один исходный плюс два повтора. Если внешний API тарифицирует запросы или имеет жесткие лимиты частоты (rate limits), лишняя попытка может стать неприятным сюрпризом.

Вторая проблема куда серьезнее и касается мутирующих методов (POST, PATCH). Представь сценарий списания средств или создания заказа:

  1. Клиент отправляет POST-запрос с телом заказа на сервер платежного шлюза.
  2. Шлюз успешно валидирует запрос, списывает баланс и фиксирует транзакцию в базе данных.
  3. В момент формирования ответа 200 OK рвется связь или балансировщик сбрасывает соединение по таймауту.
  4. Клиент go-retryablehttp видит сетевую ошибку connection reset by peer, перематывает буфер тела запроса и честно отправляет его во второй раз.

Итог закономерен: если API на той стороне не обучено отслеживать повторы, у пользователя спишутся деньги дважды. Повторять «вслепую» на транспортном уровне безопасно только идемпотентные методы по стандарту HTTP (GET, HEAD, PUT, DELETE). Для мутирующих операций обязателен прикладной заголовок Idempotency-Key с уникальным UUID операции:

// Генерация заголовка идемпотентности перед отправкой
req, err := retryablehttp.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(payload))
if err != nil {
    return err
}

// Уникальный идентификатор гарантирует, что шлюз вернет кэшированный ответ вместо дублирования
req.Header.Set("Idempotency-Key", orderUUID.String())
req.Header.Set("Content-Type", "application/json")

Кастомная политика CheckRetry и случайный джиттер

Еще один нюанс, о котором часто забывают — поведение бэкоффа. Стандартная функция задержки DefaultBackoff в go-retryablehttp честно увеличивает паузу экспоненциально, но не добавляет к ней случайный джиттер (jitter). Если внешний микросервис кратковременно моргнул, сотни параллельных инстансов твоего приложения пойдут на повтор синхронно, создавая классическую проблему «громоподобного стада» (thundering herd) и окончательно добивая лежащий бэкенд.

Правильная инициализация клиента требует переопределения функции бэкоффа со случайной добавкой и контроля отмены контекста:

package main

import (
    "context"
    "math/rand"
    "net/http"
    "time"

    "github.com/hashicorp/go-retryablehttp"
)

func createSafeClient() *http.Client {
    retryClient := retryablehttp.NewClient()
    retryClient.RetryMax = 2 // 1 исходный + 2 повтора
    retryClient.RetryWaitMin = 100 * time.Millisecond
    retryClient.RetryWaitMax = 2 * time.Second

    // Добавляем случайный джиттер до 25% от интервала
    retryClient.Backoff = func(min, max time.Duration, attemptNum int, resp *http.Response) time.Duration {
        mult := time.Duration(1 << uint(attemptNum))
        sleep := min * mult
        if sleep > max {
            sleep = max
        }
        jitter := time.Duration(rand.Int63n(int64(sleep / 4)))
        return sleep + jitter
    }

    // Отмена контекста немедленно останавливает цикл повторов
    retryClient.CheckRetry = func(ctx context.Context, resp *http.Response, err error) (bool, error) {
        if ctx.Err() != nil {
            return false, ctx.Err()
        }
        return retryablehttp.DefaultRetryPolicy(ctx, resp, err)
    }

    return retryClient.StandardClient()
}
Потребление оперативной памяти

Помни, что для неперематываемых интерфейсов io.Reader библиотека вычитывает все тело запроса в срез байтов в оперативной памяти. Если через go-retryablehttp отправляются файлы размером в сотни мегабайт, это вызовет резкий скачок потребления RAM и нагрузку на GC.

В сухом остатке: библиотека go-retryablehttp великолепно снимает транспортную рутину, но архитектурная ответственность за идемпотентность данных всегда остается на разработчике.