Go: Один бюджет времени на попытки — задача с проверкой

Практическая задача на Go по теме «Один бюджет времени на попытки». Условие содержит контракт функций, предоставленные типы и требования к результату. Используйте выданный шаблон и сохраняйте его публичные объявления. Перед проверкой разберите успешный путь, обработку ошибок и отмену операции, когда она предусмотрена контрактом. Здесь оценивается конкретный компонент сервиса; в полном курсе микросервисов на Go эти компоненты связываются с HTTP, PostgreSQL, gRPC и Kafka.

В этом задании вы реализуете функцию, которая запускает до трёх кооперативных вызовов одной сохранённой операции Label, не выходя за рамки единственного входящего контекста. Никакого нового контекста, никакого реального сна, никакого HTTP внутри оцениваемой функции.

Контекст как единственный бюджет

Входящий запрос принёс ctx с дедлайном или возможностью отмены. Этот контекст — общий бюджет для всей серии. Функция принимает его и передаёт каждому вызову attempt без изменений: один и тот же объект, проверяемый по идентичности.

Если создать context.WithTimeout(context.Background(), d) внутри исполнителя, новый контекст потеряет связь с исходным запросом: клиент ушёл — сервер продолжает попытки. Здесь эта ошибка исключена по конструкции.

Важный предел: ctx.Done() — кооперативный сигнал. Если attempt игнорирует переданный контекст и не возвращается, исполнитель не может прервать его силой. После возврата attempt повторная проверка ctx.Err() обнаружит завершение бюджета, даже если сам callback вернул решение.

Предоставленные типы

Эти объявления зафиксированы и не подлежат изменению. Ваш код использует их как есть.

import (
    "context"
    "errors"
    "time"
)

type RetryAction string

const (
    ActionStop   RetryAction = "stop"
    ActionLookup RetryAction = "lookup"
    ActionRetry  RetryAction = "retry"
)

type RetryDecision struct {
    Action      RetryAction
    WaitSeconds int
}

type RetryTimer struct {
    C    <-chan time.Time
    Stop func()
}

var ErrAttemptsExhausted = errors.New("label attempts exhausted")

Сигнатура оцениваемой функции

func RunLabelRetries(
    ctx      context.Context,
    attempt  func(context.Context) RetryDecision,
    newTimer func(time.Duration) RetryTimer,
    jitter   func(int) time.Duration,
) (RetryDecision, error)

Валидные входные данные

  • ctx — ненулевой контекст входящего запроса, возможно уже отменённый или с истёкшим дедлайном. Передаётся по идентичности каждому attempt. Никакого нового Background, TODO, WithoutCancel или отдельного таймаута на попытку.
  • attempt — ненулевой синхронный кооперативный callback, замыкающий сохранённое намерение Label (один request_id, один parcel_id, один Idempotency-Key). Возвращает одно из трёх решений. Использует переданный контекст и возвращается после его отмены, не блокируя вечно. Функция не создаёт HTTP-запросы и не разбирает заголовки — это сделала 6.3.
  • Решение stop/lookup: WaitSeconds=0. Решение retry: WaitSeconds в диапазоне 0–60 в пределах этой задачи. Production-интеграция должна отдельно обработать бо́льшие значения.
  • newTimer — ненулевая фабрика, вызывается только для планируемого ожидания с неотрицательной time.Duration. Возвращает RetryTimer с ненулевым каналом C и ненулевым Stop. Stop безопасно вызывать ровно один раз для каждого созданного таймера.
  • jitter — ненулевой детерминированный чистый callback без побочных эффектов. Вызывается ровно один раз для каждого планируемого ожидания: с индексом 1 для первого и с индексом 2 для второго. Возвращает time.Duration в диапазоне [0, 50 ms].

Nil-зависимости, некорректные решения и паники фабрики находятся за пределами допустимых входов этой задачи.

Правила выполнения

  • Проверяйте ctx.Err() перед каждым вызовом attempt, после каждого возврата attempt и после каждого пробуждения таймера. Если контекст завершён — возвращайте RetryDecision{} и сам ctx.Err().
  • Передавайте один и тот же ctx в каждый вызов attempt.
  • Максимум три вызова attempt: начальный и не более двух повторов.
  • Базовый backoff: 100 ms после первой попытки, 200 ms после второй.
  • Формула ожидания: max(base + jitter(retryIndex), time.Duration(decision.WaitSeconds) * time.Second).
  • Индексы jitter: 1 для первого ожидания, 2 для второго.
  • В каждом ожидании используйте select по ctx.Done() и timer.C.
  • Вызывайте timer.Stop() ровно один раз для каждого созданного таймера — в любой ветке выхода из ожидания.
  • После select проверяйте ctx.Err() независимо от выбранной ветки: если оба канала готовы, это делает поведение детерминированным.
  • ActionStop и ActionLookup — терминальные: возвращаются немедленно с nil, без jitter, таймера и следующего вызова.
  • Если третий вызов вернул ActionRetry при живом контексте — возвращайте это решение и ErrAttemptsExhausted. Четвёртого вызова и третьего ожидания нет.
  • Отмена контекста имеет приоритет над решением callback и над ErrAttemptsExhausted: при завершённом контексте возвращайте RetryDecision{} и ctx.Err().
  • Никакого time.After, реального time.Sleep, глобального источника случайности, добавленных горутин, изменяемого глобального состояния и HTTP-вызовов.

Что контекст не гарантирует

Контекст не прерывает принудительно кооперативный callback, если тот игнорирует его. Истечение контекста не доказывает, что сервер отклонил уже начатый POST /labels до его принятия. Нулевое решение при отмене сообщает об отсутствии финального решения серии — не о судьбе уже отправленного запроса. Именно поэтому все попытки в серии несут одно и то же сохранённое намерение: тот же request_id, тот же parcel_id, тот же Idempotency-Key. Менять их между попытками запрещено — это превратило бы повтор в новую операцию.

Таблица исходов

  • Контекст завершён до входа — 0 вызовов, 0 таймеров, нулевое решение, ctx.Err().
  • Первый stop или lookup — 1 вызов, 0 таймеров, исходное решение, nil.
  • retry → stop — 2 вызова, 1 таймер, jitter(1), Stop таймера, исходное решение, nil.
  • retry → retry → stop/lookup — 3 вызова, 2 таймера, jitter(1) и jitter(2), оба Stop, терминальное решение, nil.
  • retry → retry → retry (живой контекст) — 3 вызова, 2 таймера, последнее решение, ErrAttemptsExhausted.
  • Отмена во время ожидания — следующего вызова нет, текущий таймер Stop, нулевое решение, ctx.Err().
  • Отмена внутри кооперативного callback — после его возврата нет нового вызова, нет нового таймера, нулевое решение, ctx.Err().

Арифметический пример

t=0   attempt(ctx) → retry/0
      jitter(1)=25ms, base=100ms, WaitSeconds=0
      wait = max(125ms, 0) = 125ms → newTimer(125ms)

t=125 attempt(ctx) → retry/0
      jitter(2)=40ms, base=200ms, WaitSeconds=0
      wait = max(240ms, 0) = 240ms → newTimer(240ms)

t=365 attempt(ctx) → lookup/0
      return lookup/0, nil

Если дедлайн контекста наступает в t=300 ms, второй таймер (сработал бы в t=365 ms) прерывается по ctx.Done(). timer.Stop() вызывается, третьей попытки нет, возвращается RetryDecision{} и context.DeadlineExceeded.

Если первая попытка вернула retry/WaitSeconds=2 при том же дедлайне: max(100+jitter(1), 2000ms) = 2000 ms. Таймер создаётся на 2000 ms, дедлайн в t=300 ms прерывает первое ожидание. Второй попытки нет.

Эти числа — арифметические трассы, не измеренное время планировщика Go. Значения 100/200 ms и лимит трёх попыток — учебные редакционные решения, не универсальные параметры сервиса.

Стартовый код

func RunLabelRetries(
    ctx      context.Context,
    attempt  func(context.Context) RetryDecision,
    newTimer func(time.Duration) RetryTimer,
    jitter   func(int) time.Duration,
) (RetryDecision, error) {
    // TODO: preserve ctx, schedule no more than three attempts and stop each timer.
    return RetryDecision{}, nil
}
РешениеGo

Без регистрации. Гостевая проверка не сохраняет прогресс. Для сохранения перейдите к уроку.

Отправьте решение, чтобы увидеть результаты тестов.

Связанные задачи