Продакшн-разработка ИИ-агентов · Модуль 6 · Урок 6.2

Глава 13. Деплой и инфраструктура

Цели главы

После этой главы вы сможете:

  • Проектировать агента как сервис (agent as a service): stateless-обработчик плюс внешнее состояние.
  • Управлять конкурентностью на Go: пул воркеров (worker pool) с ограничением параллелизма, context для отмены и таймаутов, безопасность разделяемого состояния, корректное завершение (graceful shutdown).
  • Выбирать хранилище состояния (сессии, история) — Redis или Postgres — и понимать, почему оно должно быть устойчивым (durable).
  • Применять очереди для асинхронных задач агента с идемпотентными воркерами и ретраями.
  • Понимать назначение durable-workflow-движков (Temporal-подобных) для долгих надёжных сценариев (обзорно).
  • Версионировать промпты как артефакт и встраивать эвалы в CI как гейт, выкатывать версии канареечно или A/B.

Что нового (дельта к базовому курсу)

В базовом курсе агент жил в одном процессе и обрабатывал один запрос за раз — состояние держалось в памяти. Это не выдерживает продакшена: процессы падают и перезапускаются, нагрузка приходит конкурентно, релизы должны откатываться.

Дельта: состояние выносится из процесса. Обработчик становится stateless, а история и сессии живут в durable-хранилище. Это даёт горизонтальное масштабирование (любой инстанс обслужит любой запрос) и переживание рестартов.

Вторая дельта — операционная зрелость: конкурентность с лимитами и таймаутами, очереди с идемпотентностью и ретраями, промпт как версионируемый артефакт, эвалы в CI как условие выката. Базовый курс этого не касался — там был happy path одного вызова.

Агент как сервис: stateless-обработчик + внешнее состояние

Продакшен-агент — это сетевой сервис (HTTP или gRPC). Ключевой принцип: обработчик не хранит состояние между запросами. Всё, что нужно помнить (история сессии, накопленный контекст, флаги режима), читается в начале обработки из внешнего хранилища и пишется обратно в конце.

Почему так: stateless-обработчик масштабируется горизонтально (балансировщик раскидывает запросы на любой инстанс), переживает рестарт пода (состояние не теряется, оно в Redis/Postgres) и упрощает откат версии (новый код не зависит от того, что лежало в памяти старого).

gRPC уместен для внутренних сервис-к-сервису вызовов (строгий контракт, стриминг), HTTP/JSON — для внешнего API и совместимости. Выбор не меняет принципа: состояние снаружи.

Конкурентность на Go

Go даёт дешёвые горутины, но «горутина на запрос без ограничений» — путь к исчерпанию памяти и упору в rate limit провайдера. Нужен worker pool с фиксированным числом воркеров: канал-семафор или пул, ограничивающий одновременные вызовы модели до безопасного N.

context.Context — обязательный инструмент: таймаут на запрос (context.WithTimeout) обрывает зависший вызов модели, отмена (context.WithCancel) распространяется вниз по цепочке, когда клиент отключился. Все вызовы SDK принимают ctx первым аргументом — пробрасывайте его сквозь.

Безопасность разделяемого состояния: любые общие счётчики, кеши, бюджеты защищайте sync.Mutex или используйте каналы для передачи владения. Гонки (data races) в Go тихие и недетерминированные — гоняйте тесты с -race.

Graceful shutdown: по сигналу (SIGTERM от оркестратора) сервис должен перестать принимать новые запросы, дать текущим доработать в пределах дедлайна и только потом выйти. http.Server.Shutdown(ctx) делает это штатно; самописные воркеры дренируют очередь по закрытию входного канала.

Хранилища, очереди и durable workflows

Хранилище состояния. Сессии и история должны быть durable — переживать рестарт. Redis хорош для быстрой сессионной памяти с TTL (горячий контекст, кеш), Postgres — для долговечной истории, аудита и транзакционных гарантий. Часто используют оба: Redis как горячий слой, Postgres как источник истины.

Очереди. Долгие или фоновые задачи агента (генерация отчёта, обработка загруженного файла) не должны блокировать HTTP-обработчик. Запрос кладёт задачу в очередь, отдельный пул воркеров её разбирает. Два требования к воркеру: идемпотентность (повторная обработка той же задачи не должна дублировать эффект — например, по ключу идемпотентности) и ретраи с backoff при сбоях. Очередь даёт развязку нагрузки и переживание пиков.

Durable workflows (обзорно). Для долгих многошаговых надёжных сценариев (агент работает минутами и часами, переживая падения воркеров) применяют движки вроде Temporal: они сохраняют состояние каждого шага (event sourcing), при сбое продолжают с места обрыва, а не с начала. Это тяжёлая инфраструктура — оправдана, когда сценарий действительно длинный и потеря прогресса дорога.

Версионирование промптов, CI/CD и A/B

Промпт — это артефакт, а не строка в коде. Версионируйте его (в репозитории или в реестре промптов) с явным номером версии, который пишется в логи каждого запроса. Тогда регрессию можно привязать к конкретной версии и откатиться (rollback), пинуя новые сессии на предыдущую версию.

Эвалы в CI как гейт. Набор эвалов (тестовых сценариев с ожидаемым поведением) прогоняется в CI на каждое изменение промпта или модели. Падение эвалов блокирует мёрдж — так же, как падение юнит-тестов. Это превращает «промпт поменяли на глаз» в контролируемый процесс.

Канареечный и A/B выкат. Новую версию промпта или модели выкатывают сначала на малую долю трафика (canary), сравнивают метрики качества и стоимости со старой (A/B), и при подтверждении раскатывают на 100% — или откатывают. Версионирование промпта делает такой раздельный выкат возможным: маршрутизатор выбирает версию по доле трафика.

Rate limits, Retry-After и backpressure

Провайдер ограничивает скорость обращений. При превышении приходит ответ 429 (rate_limit_error) с заголовком Retry-After — числом секунд, через которое можно повторить. Отдельно бывает 529 (overloaded_error) — сервис временно перегружен. Заголовки семейства anthropic-ratelimit-* (например, anthropic-ratelimit-requests-remaining, anthropic-ratelimit-tokens-remaining) показывают остаток вашей квоты — конкретные имена и числа сверяйте с актуальными доками.

Правило номер один: уважайте Retry-After, не «долбите сразу». Повтор без паузы только усугубляет перегрузку и продлевает блокировку. На ретраях используйте экспоненциальный backoff с джиттером (exponential backoff + jitter): каждая следующая пауза вдвое больше, плюс случайная добавка, чтобы тысячи клиентов не пошли на повтор синхронно (thundering herd). Если Retry-After пришёл — он приоритетнее вашей формулы. Свяжите это с circuit breaker из главы 10: после серии отказов размыкайте цепь и не шлите запросы вовсе, пока провайдер не оправится.

Важная тонкость: автоматический повтор безопасен только для идемпотентных вызовов. Обычный запрос к модели идемпотентен в смысле «повтор не создаёт лишних побочных эффектов на вашей стороне», но если вокруг вызова есть нетранзакционные сайд-эффекты (списание, запись в очередь), повтор нужно защищать ключом идемпотентности, как в воркере очереди.

anthropic-sdk-go уже умеет ретраить 429 и 5xx с backoff и сам уважает Retry-After (число ретраев настраивается опцией, например WithMaxRetries). Это снимает базовый случай. Но для своей бизнес-логики — backpressure, деградация, метрики — дублируйте аккуратно поверх, не борясь с SDK.

Backpressure (противодавление). Rate limit — это сигнал, что вы шлёте быстрее, чем можно. Реакция — не бесконечно копить запросы в памяти, а ограничивать. Ограничьте конкурентность семафором; ставьте запросы в очередь с лимитом длины; при переполнении — деградируйте или отклоняйте (load shedding): верните клиенту честный 503/«занято» вместо того, чтобы накапливать очередь до OOM и каскадного отказа. Лучше отказать быстро и предсказуемо, чем зависнуть всем сразу.

Мультитенантность, квоты и регресс при миграции модели

Мультитенантность и квоты. Если сервис агента обслуживает многих клиентов (тенантов) через один общий лимит провайдера, один прожорливый тенант способен выесть всю квоту и заморить остальных (проблема «шумного соседа»). Решение — разделить бюджеты и лимиты по тенантам: per-tenant token budget (см. главу 12) и per-tenant rate limit. Каждый тенант получает свою долю; справедливое распределение (fair share) гарантирует, что всплеск у одного не обрушит SLA у других. Технически это тот же семафор/счётчик, но ключёванный по тенанту.

Регрессионная проверка при миграции версии модели. Слаги моделей меняются от релиза к релизу, и поведение модели — тоже. Поэтому смена слага модели — это изменение, требующее гейта, а не безобидная правка строки. Перед выкатом новой версии прогоняйте на ней eval-датасет (см. главу 9) и сравнивайте метрики со старой моделью; падение ниже порога блокирует выкат — ровно как падение тестов в CI. Тогда новый релиз модели становится возможностью, а не риском: апгрейд занимает дни, а не недели, потому что у вас есть автоматический ответ на вопрос «стало лучше или хуже?».

Это замыкается на data flywheel из главы 9: продакшен-трафик и разметка пополняют eval-датасет, на котором вы и проверяете каждую новую модель. Чем богаче маховик данных, тем надёжнее регресс-гейт и тем спокойнее миграции.

HTTP-сервис агента: worker-pool, context-таймаут, graceful shutdown
package agentsvc

import (
	"context"
	"encoding/json"
	"errors"
	"log"
	"net/http"
	"os"
	"os/signal"
	"syscall"
	"time"

	"github.com/anthropics/anthropic-sdk-go"
)

// SessionStore — внешнее durable-хранилище состояния (Redis/Postgres).
// Обработчик stateless: историю читаем в начале, пишем в конце.
type SessionStore interface {
	Load(ctx context.Context, sessionID string) ([]anthropic.MessageParam, error)
	Save(ctx context.Context, sessionID string, msgs []anthropic.MessageParam) error
}

// Server — агент как сервис.
type Server struct {
	client anthropic.Client
	store  SessionStore
	// sem — семафор-пул: ограничивает число одновременных вызовов модели,
	// чтобы не исчерпать память и не упереться в rate limit провайдера.
	sem chan struct{}
}

func NewServer(client anthropic.Client, store SessionStore, maxConcurrent int) *Server {
	return &Server{
		client: client,
		store:  store,
		sem:    make(chan struct{}, maxConcurrent),
	}
}

type chatRequest struct {
	SessionID string // json:"session_id"
	Message   string // json:"message"
}

type chatResponse struct {
	Reply string // json:"reply"
}

func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
	// Таймаут на запрос: зависший вызов модели будет оборван по дедлайну,
	// а отмена клиента распространится вниз по ctx.
	ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
	defer cancel()

	var req chatRequest
	if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
		http.Error(w, "bad request", http.StatusBadRequest)
		return
	}

	// Занимаем слот в пуле; если все заняты — ждём или отдаём 503 по таймауту.
	select {
	case s.sem <- struct{}{}:
		defer func() { <-s.sem }()
	case <-ctx.Done():
		http.Error(w, "busy", http.StatusServiceUnavailable)
		return
	}

	// 1. Читаем состояние из внешнего хранилища (stateless-обработчик).
	history, err := s.store.Load(ctx, req.SessionID)
	if err != nil {
		http.Error(w, "store error", http.StatusInternalServerError)
		return
	}

	// 2. Добавляем реплику пользователя и вызываем модель.
	history = append(history,
		anthropic.NewUserMessage(anthropic.NewTextBlock(req.Message)))

	resp, err := s.client.Messages.New(ctx, anthropic.MessageNewParams{
		Model:     anthropic.ModelClaudeOpus4_8,
		MaxTokens: 4096,
		Messages:  history,
	})
	if err != nil {
		http.Error(w, "model error", http.StatusBadGateway)
		return
	}

	// 3. Сохраняем обновлённую историю обратно (ToParam конвертирует ответ).
	history = append(history, resp.ToParam())
	if err := s.store.Save(ctx, req.SessionID, history); err != nil {
		http.Error(w, "store error", http.StatusInternalServerError)
		return
	}

	var reply string
	for _, block := range resp.Content {
		if t, ok := block.AsAny().(anthropic.TextBlock); ok {
			reply += t.Text
		}
	}
	_ = json.NewEncoder(w).Encode(chatResponse{Reply: reply})
}

// Run поднимает сервер и обеспечивает graceful shutdown по SIGTERM/SIGINT.
func (s *Server) Run(addr string) error {
	mux := http.NewServeMux()
	mux.HandleFunc("/chat", s.handleChat)
	srv := &http.Server{Addr: addr, Handler: mux}

	// Слушаем сигналы завершения от оркестратора.
	stop := make(chan os.Signal, 1)
	signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)

	go func() {
		if err := srv.ListenAndServe(); err != nil &&
			!errors.Is(err, http.ErrServerClosed) {
			log.Fatalf("listen: %v", err)
		}
	}()

	<-stop
	// Перестаём принимать новые запросы, даём текущим доработать в дедлайн.
	shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
	defer cancel()
	return srv.Shutdown(shutdownCtx)
}
Очередь асинхронных задач: пул воркеров, идемпотентность, дренаж по shutdown
package agentsvc

import (
	"context"
	"log"
	"sync"
)

// Task — асинхронная задача агента (например, генерация отчёта).
// IdempotencyKey защищает от двойного выполнения при ретрае.
type Task struct {
	ID             string
	IdempotencyKey string
	Payload        string
}

// ProcessedSet — хранилище уже обработанных ключей (в проде — Redis/Postgres).
type ProcessedSet interface {
	// SeenOrMark возвращает true, если ключ уже обработан; иначе помечает его.
	SeenOrMark(ctx context.Context, key string) (seen bool, err error)
}

// Pool — пул воркеров, разбирающий задачи из канала.
type Pool struct {
	tasks     chan Task
	processed ProcessedSet
	handle    func(ctx context.Context, t Task) error
	wg        sync.WaitGroup
}

func NewPool(workers int, processed ProcessedSet, handle func(context.Context, Task) error) *Pool {
	p := &Pool{
		tasks:     make(chan Task, 256),
		processed: processed,
		handle:    handle,
	}
	for i := 0; i < workers; i++ {
		p.wg.Add(1)
		go p.worker()
	}
	return p
}

func (p *Pool) worker() {
	defer p.wg.Done()
	ctx := context.Background()
	// Цикл завершится, когда канал tasks закроют (graceful shutdown).
	for t := range p.tasks {
		// Идемпотентность: повторно пришедшую задачу не выполняем дважды.
		seen, err := p.processed.SeenOrMark(ctx, t.IdempotencyKey)
		if err != nil {
			log.Printf("task %s: processed-check failed: %v", t.ID, err)
			continue
		}
		if seen {
			continue
		}
		if err := p.handle(ctx, t); err != nil {
			// В проде здесь ретрай с backoff или отправка в dead-letter-очередь.
			log.Printf("task %s failed: %v", t.ID, err)
		}
	}
}

// Submit кладёт задачу в очередь.
func (p *Pool) Submit(t Task) { p.tasks <- t }

// Shutdown закрывает вход и ждёт, пока воркеры дренируют очередь.
func (p *Pool) Shutdown() {
	close(p.tasks)
	p.wg.Wait()
}
Обёртка ретраев: уважаем Retry-After на 429, backoff+jitter, только для идемпотентных вызовов
package agentsvc

import (
	"context"
	"errors"
	"math"
	"math/rand"
	"strconv"
	"time"

	"github.com/anthropics/anthropic-sdk-go"
)

// callModel — функция одного вызова модели. Она ДОЛЖНА быть идемпотентной:
// повтор не создаёт побочных эффектов на нашей стороне. Иначе ретраить нельзя
// без ключа идемпотентности (см. воркер очереди выше).
type callModel func(context.Context) (*anthropic.Message, error)

// retryAfterSeconds достаёт паузу из заголовка Retry-After ответа об ошибке.
// Возвращает 0, если заголовка нет. Заголовки 429/529 и anthropic-ratelimit-*
// сверяйте с актуальными доками — имена и числа меняются.
func retryAfterSeconds(err error) (time.Duration, bool) {
	var apiErr *anthropic.Error
	if !errors.As(err, &apiErr) {
		return 0, false
	}
	// Ретраим только перегрузку/лимит: 429 (rate_limit) и 529 (overloaded).
	if apiErr.StatusCode != 429 && apiErr.StatusCode != 529 {
		return 0, false
	}
	if v := apiErr.Response.Header.Get("Retry-After"); v != "" {
		if secs, perr := strconv.Atoi(v); perr == nil {
			return time.Duration(secs) * time.Second, true
		}
	}
	// 429/529 без Retry-After: ретраим, но паузу посчитаем сами (backoff).
	return 0, true
}

// CallWithRetry повторяет идемпотентный вызов с уважением к Retry-After,
// иначе — экспоненциальный backoff с джиттером. SDK уже делает базовые ретраи
// (настраиваются через option.WithMaxRetries); здесь — слой нашей бизнес-логики.
func CallWithRetry(
	ctx context.Context,
	call callModel,
	maxAttempts int,
) (*anthropic.Message, error) {
	const base = 500 * time.Millisecond
	const cap = 30 * time.Second

	var lastErr error
	for attempt := 0; attempt < maxAttempts; attempt++ {
		msg, err := call(ctx)
		if err == nil {
			return msg, nil
		}
		lastErr = err

		wait, retryable := retryAfterSeconds(err)
		if !retryable {
			// Не лимит и не перегрузка — повтор не поможет, отдаём ошибку.
			return nil, err
		}
		if wait == 0 {
			// Retry-After не пришёл: экспоненциальный backoff + джиттер,
			// чтобы клиенты не пошли на повтор синхронно (thundering herd).
			backoff := time.Duration(float64(base) * math.Pow(2, float64(attempt)))
			if backoff > cap {
				backoff = cap
			}
			wait = backoff + time.Duration(rand.Int63n(int64(base)))
		}

		select {
		case <-time.After(wait):
		case <-ctx.Done():
			return nil, ctx.Err()
		}
	}
	return nil, lastErr
}
Backpressure: per-tenant семафор конкурентности с load shedding
package agentsvc

import (
	"context"
	"errors"
	"sync"
)

// ErrTenantBusy возвращается, когда тенант исчерпал свою долю конкурентности.
// На уровне HTTP это превращается в 503 (load shedding), а не в бесконечную
// очередь до OOM.
var ErrTenantBusy = errors.New("tenant concurrency limit reached")

// TenantLimiter ограничивает число одновременных вызовов модели ОТДЕЛЬНО
// для каждого тенанта, чтобы «шумный сосед» не выел общий лимит провайдера.
type TenantLimiter struct {
	mu       sync.Mutex
	perLimit int
	sems     map[string]chan struct{}
}

func NewTenantLimiter(perTenant int) *TenantLimiter {
	return &TenantLimiter{
		perLimit: perTenant,
		sems:     make(map[string]chan struct{}),
	}
}

// semFor лениво создаёт семафор для тенанта.
func (l *TenantLimiter) semFor(tenant string) chan struct{} {
	l.mu.Lock()
	defer l.mu.Unlock()
	sem, ok := l.sems[tenant]
	if !ok {
		sem = make(chan struct{}, l.perLimit)
		l.sems[tenant] = sem
	}
	return sem
}

// Do выполняет fn под лимитом конкретного тенанта. Если слотов нет —
// немедленно отклоняет (ErrTenantBusy), а не копит очередь бесконечно.
// Так всплеск одного тенанта не обрушивает SLA остальных.
func (l *TenantLimiter) Do(
	ctx context.Context,
	tenant string,
	fn func(context.Context) error,
) error {
	sem := l.semFor(tenant)
	select {
	case sem <- struct{}{}:
		defer func() { <-sem }()
	default:
		// Слотов тенанта нет: отклоняем быстро (load shedding).
		return ErrTenantBusy
	}

	// Если хотим короткое ожидание вместо мгновенного отказа — здесь можно
	// добавить select с ctx и небольшим таймаутом перед тем, как сдаться.
	return fn(ctx)
}

Anti-patterns

Грабли деплоя и инфраструктуры
ГрабляПочему плохоКак избегать
Хранить историю сессий в памяти процессаСостояние теряется при рестарте и не масштабируется горизонтально — балансировщик ломает сессииStateless-обработчик + внешнее durable-хранилище (Redis/Postgres); читать в начале, писать в конце
Горутина на запрос без ограничения параллелизмаИсчерпание памяти под нагрузкой и упор в rate limit провайдераWorker pool с семафором: фиксированное N одновременных вызовов модели
Вызовы модели без context-таймаутаЗависший вызов держит соединение и слот вечно; отмена клиента не доходитcontext.WithTimeout на запрос, пробрасывать ctx во все вызовы SDK
Завершение процесса по SIGTERM мгновенным выходомТекущие запросы обрываются, задачи в очереди теряютсяGraceful shutdown: srv.Shutdown(ctx) и дренаж пула по закрытию канала
Неидемпотентный воркер очередиРетрай или повторная доставка дублирует эффект (двойной отчёт, двойное списание)Ключ идемпотентности + проверка SeenOrMark перед выполнением
Промпт как строка в коде без версииРегрессию нельзя привязать к версии и откатить; A/B невозможенПромпт как версионируемый артефакт; эвалы в CI как гейт; канареечный/A-B выкат по версии
Повтор после 429 сразу, игнорируя Retry-AfterУсугубляет перегрузку, продлевает блокировку, синхронный thundering herd от всех клиентовУважать Retry-After; иначе экспоненциальный backoff + джиттер; связать с circuit breaker (глава 10)
Бесконечно копить запросы при rate limit вместо backpressureОчередь растёт до OOM, латентность взрывается, каскадный отказ всего сервисаОграничить конкурентность семафором, лимит очереди, при переполнении — load shedding (быстрый 503)
Один общий лимит на всех тенантов«Шумный сосед» выедает квоту провайдера и морит остальных, ломая их SLAPer-tenant token budget и rate limit; справедливое распределение, семафор по ключу тенанта
Смена слага модели без регрессионного гейтаНовая версия модели тихо ухудшает поведение; деградацию замечают в продеПрогон eval-датасета (глава 9) как гейт перед выкатом; миграция — возможность, а не риск

Практическое задание (pro-m6-deploy-infra)

  • Реализуйте SessionStore поверх Redis (горячая сессия с TTL) или Postgres (durable история); обработчик читает историю в начале и пишет в конце.
  • Ограничьте параллелизм вызовов модели семафором-пулом; параметр N вынесите в конфиг и подберите под rate limit провайдера.
  • Пробросьте context.WithTimeout сквозь весь путь запроса; проверьте, что отмена клиента обрывает вызов модели.
  • Добавьте graceful shutdown: по SIGTERM сервер перестаёт принимать запросы, пул дренирует очередь, процесс выходит в пределах дедлайна.
  • Вынесите долгую задачу (генерацию отчёта) в очередь с идемпотентным воркером и ретраями; проверьте, что повторная доставка не дублирует эффект.
  • Привяжите номер версии промпта к каждому запросу в логах и настройте прогон эвалов в CI как условие мёрджа; продумайте канареечный выкат новой версии.
  • Оберните вызовы модели в ретраи, читающие Retry-After на 429/529, с backoff+jitter только для идемпотентных вызовов; убедитесь, что SDK-ретраи (WithMaxRetries) и ваша логика не дублируют друг друга бесконтрольно.
  • Введите per-tenant лимиты: семафор конкурентности и token budget по ключу тенанта; при переполнении отклоняйте быстро (load shedding), а перед сменой слага модели прогоняйте eval-датасет как регресс-гейт.

Проверка знаний

Сервис агента запущен в трёх подах за балансировщиком; история сессий лежит в памяти каждого процесса.

Какая проблема проявится в первую очередь?

  • A Слишком высокая стоимость токенов
  • B Последовательные запросы одной сессии попадают на разные поды и теряют контекст
  • C Стриминг перестанет работать
  • D Промпт нельзя будет версионировать

Воркер очереди генерирует отчёт и списывает кредиты. Из-за ретрая та же задача приходит дважды.

Что предотвращает двойное списание?

  • A Увеличение числа воркеров в пуле
  • B Context-таймаут на задачу
  • C Идемпотентность: проверка ключа перед выполнением
  • D Стриминг ответа

Оркестратор посылает поду SIGTERM при выкате новой версии.

Как корректно завершить сервис, не оборвав текущие запросы?

  • A Немедленно вызвать os.Exit(0)
  • B Перестать принимать новые запросы, дать текущим доработать в дедлайн, затем выйти (graceful shutdown)
  • C Игнорировать сигнал и ждать, пока процесс убьют
  • D Сбросить все сессии из памяти и перезапуститься

Сервис агента под нагрузкой начал получать ответы 429 с заголовком Retry-After. Разработчик повторяет запрос немедленно в цикле, а очередь входящих запросов копится в памяти без ограничения.

Что здесь сделано неправильно и как должно быть?

  • A Всё верно: чем быстрее повторять, тем скорее запрос пройдёт
  • B Нужно уважать Retry-After (или backoff+jitter, если его нет) и применять backpressure: ограничить конкурентность, а при переполнении отклонять (load shedding), а не копить очередь до OOM
  • C Достаточно увеличить MaxTokens, чтобы запросы реже отклонялись
  • D Надо просто отключить ретраи SDK и слать запросы напрямую