Retry-After в Go: секунды, HTTP-дата и оставшееся время

Зависимый API отвечает 429 и присылает Retry-After. В тесте стоит значение 5, поэтому клиент вызывает strconv.Atoi и ждёт пять секунд. У другого поставщика заголовок содержит HTTP-дату, и тот же клиент немедленно повторяет запрос, приняв ошибку разбора за нулевую задержку. Ограничение сервера фактически игнорируется.

Две формы одного заголовка

У Retry-After есть две формы: неотрицательное целое число секунд и HTTP-дата. Для второй формы в Go подходит http.ParseTime. Дату нужно сравнить с выбранным now, получить длительность и обработать уже прошедшее значение. Передавать now параметром полезно для воспроизводимых тестов: проверка не зависит от часов машины в момент запуска.

Даже корректная задержка может не помещаться в оставшееся время запроса. Бессмысленно ждать пять секунд, если у всей операции осталось две. В примере парсер принимает ещё budget и возвращает ошибку, когда пауза превышает его. Это явное решение клиента прекратить работу, а не обещание выполнить retry любой ценой.

Здесь проверяется только интерпретация указания сервера. Разрешение повторить меняющую операцию определяется её идемпотентностью и известностью исхода предыдущей попытки. Заголовок не превращает POST в безопасную операцию. Эту границу полезно держать отдельно от разбора синтаксиса, чтобы один рабочий парсер не стал случайной универсальной политикой повторов.

Разобрать задержку без переполнения

package main

import (
	"fmt"
	"net/http"
	"strconv"
	"strings"
	"time"
)

func retryDelay(raw string, now time.Time, budget time.Duration) (time.Duration, error) {
	raw = strings.TrimSpace(raw)
	if raw == "" || budget < 0 {
		return 0, fmt.Errorf("missing header or expired budget")
	}
	numeric := true
	for _, c := range raw {
		if c < '0' || c > '9' {
			numeric = false
		}
	}
	var delay time.Duration
	if numeric {
		seconds, err := strconv.ParseUint(raw, 10, 64)
		if err != nil || seconds > uint64(budget/time.Second) {
			return 0, fmt.Errorf("delay exceeds budget")
		}
		delay = time.Duration(seconds) * time.Second
	} else {
		moment, err := http.ParseTime(raw)
		if err != nil {
			return 0, fmt.Errorf("invalid Retry-After: %w", err)
		}
		delay = moment.Sub(now)
		if delay < 0 {
			delay = 0
		}
	}
	if delay > budget {
		return 0, fmt.Errorf("delay exceeds budget")
	}
	return delay, nil
}

func main() {
	now := time.Date(2026, 1, 2, 7, 0, 0, 0, time.UTC)
	for _, raw := range []string{"5", now.Add(5 * time.Second).Format(http.TimeFormat)} {
		delay, err := retryDelay(raw, now, 8*time.Second)
		if err != nil || delay != 5*time.Second {
			panic("wrong delay")
		}
		fmt.Println(delay)
	}
	for _, raw := range []string{"9", "-1", "oops", "9999999999999999999999999"} {
		if _, err := retryDelay(raw, now, 8*time.Second); err == nil {
			panic("invalid or excessive delay accepted")
		}
	}
}

Числовая ветка проверяет верхнюю границу до преобразования в Duration и умножения. Иначе огромное число секунд может переполнить целочисленное представление и превратиться в короткое или отрицательное ожидание. Знак плюс и дробные секунды не входят в выбранный числовой формат: такой вход попадёт в разбор даты и будет отвергнут.

Ожидать в бюджете операции

Для HTTP-даты точность и корректность часов имеют значение. Если машины сильно расходятся по времени, вычисленная задержка будет неверной. При работе с внешним API полезно наблюдать ответы Date и следить за синхронизацией системных часов. Не компенсируйте произвольное расхождение молчаливой подстановкой нуля: это снова создаст слишком ранние запросы.

После выбора паузы ожидайте её с учётом context.Done, чтобы отмена входящего запроса прервала ожидание. Общий deadline должен включать все попытки и паузы. При этом оставьте время на сам следующий обмен: budget в примере относится только к допустимой задержке, а вызывающий код определяет резерв на работу.

Сохраните тест обеих форм, прошлой даты, пустого значения и чрезмерно большого числа. Для прошлой даты выбран нулевой интервал, но право на немедленный повтор всё ещё решает политика операции. Так синтаксис заголовка и безопасность повтора остаются двумя явно проверяемыми правилами.

Продолжить практику

В курсе микросервисов на Go есть общий бюджет попыток, идемпотентность и защита зависимости от перегрузки. Откройте программу и продолжите от разбора Retry-After к полному сценарию: один ключ операции, ограниченное время и понятный исход после потерянного ответа.

Продолжить на практике

  • Микросервисы на Go: HTTP, PostgreSQL, gRPC, Kafka и Docker ↗

    От работающего Go API — к взаимодействию сервисов, которому можно доверять. Разберитесь, как проектировать контракты, сохранять согласованность данных и восстанавливать обработку после сбоев. HTTP, PostgreSQL, gRPC, Kafka и Docker — через код и наглядные разборы.