← назад к разделу

API: REST И КОНТРАКТЫ Batch, async, локализация

Групповые операции и долгие задачи в Go

Go · REST

Частичный успех пакета, 202 Accepted для длительной работы и локализованное сообщение при стабильном коде ошибки.

1. Batch возвращает результат каждого элемента

1 / 5

Пакет ограничивается размером и обрабатывается по элементам. Ошибка одного ID не обязана скрывать успех остальных.

  • Результат связывается с входным ID или index.
  • Частичный успех описывается явно.
  • Транзакционная атомарность выбирается доменом.
Batch [1,2,3]limit 1001 → success2 → NOT_FOUND3 → successresults[]

2. 202 принимает работу, не обещает завершение

2 / 5

Долгая задача создаёт операцию и возвращает 202 Accepted, Location на статус и идентификатор.

  • Работа продолжается асинхронно.
  • Статус имеет состояния и дедлайн.
  • Клиент опрашивает с интервалом или получает webhook.
POST /exports202 AcceptedjobID: J-42GET /jobs/J-42Location → статус операции

3. Статус задачи показывает прогресс и отказ

3 / 5

QUEUED, RUNNING, SUCCEEDED и FAILED понятны клиенту. После завершения ссылка ведёт на готовый ресурс.

  • Повтор polling идемпотентен.
  • Retry-After регулирует частоту.
  • Ошибка задачи сохраняет стабильный code.
QUEUEDRUNNINGSUCCEEDEDFAILEDResult URL202 не означает успешный результат

4. Код ошибки стабилен, сообщение локализовано

4 / 5

Клиентская логика использует code, а Accept-Language влияет только на человекочитаемый message и title.

  • Локализация выбирается из известного каталога.
  • Неподдержанный язык получает default.
  • Поле field и машинный code не переводятся.
Accept-Language: ru"code": "LIMIT_EXCEEDED""message": "Лимит превышен"Accept-Language: en"code": "LIMIT_EXCEEDED""message": "Limit exceeded"Логика по коду, чтение на языке пользователя

5. Повтор команды не создаёт вторую задачу

5 / 5

Batch и запуск async получают Idempotency-Key. Повтор возвращает тот же набор результатов или jobID.

  • Статус хранится устойчиво к рестарту.
  • Фоновая обработка допускает повторное выполнение.
  • Готовый результат дедуплицируется.
POST + K-42retryK-42 → J-42same jobIDОдна команда - одна отслеживаемая операция

Три ситуации, которые не укладываются в стандартный CRUD: нужно обработать сразу сотню объектов, задача занимает несколько минут, или пользователь ожидает сообщение об ошибке на своём языке. В Go каждая из этих задач решается явным кодом — никакой магии фреймворка, всё видно в одном файле.

Групповые операции: когда нужно обработать много объектов сразу

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

Важный принцип: частичный успех. Если из 50 заказов три не прошли — это не ошибка всего запроса. Сервер возвращает 200 OK и отдельный результат на каждый элемент: успех или причину отказа. Клиент сам решает, что делать с теми тремя.

Структуры запроса и ответа

В Go удобно использовать обобщённые типы (дженерики), чтобы не писать одно и то же для каждого домена:

type BatchRequest[T any] struct {
    Items []T `json:"items" validate:"required,min=1,max=100"`
}

type BatchResult[T any] struct {
    ID      string     `json:"id"`
    Success bool       `json:"success"`
    Data    *T         `json:"data,omitempty"`
    Error   *ItemError `json:"error,omitempty"`
}

type ItemError struct {
    Code   string `json:"code"`
    Detail string `json:"detail"`
}

type BatchResponse[T any] struct {
    Results []BatchResult[T] `json:"results"`
    Summary BatchSummary     `json:"summary"`
}

type BatchSummary struct {
    Total   int `json:"total"`
    Success int `json:"success"`
    Failed  int `json:"failed"`
}

ItemError намеренно упрощён: только code и detail. Полный формат ошибки нужен для HTTP-ответа в целом — для конкретного элемента в списке достаточно минимума.

Роутинг и эндпоинт

Групповая операция — это всегда POST, эндпоинт строится как /resources/batch или /resources/batch/<действие>:

r.Route("/api/v1", func(r chi.Router) {
    r.Route("/orders", func(r chi.Router) {
        r.Post("/batch", batchCreateOrders)
        r.Post("/batch/confirm", batchConfirmOrders)
    })
    r.Route("/customers", func(r chi.Router) {
        r.Post("/batch", batchUpdateCustomers)
    })
})

Пример обработчика

type CreateOrderItem struct {
    ProductID string `json:"productId" validate:"required"`
    Quantity  int    `json:"quantity"  validate:"required,min=1"`
}

func batchCreateOrders(w http.ResponseWriter, r *http.Request) {
    var req BatchRequest[CreateOrderItem]
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        httperr.Write(w, r, apperr.NewValidation("invalid request body"))
        return
    }
    if err := validate.Struct(req); err != nil {
        var ve validator.ValidationErrors
        if errors.As(err, &ve) {
            if isMaxExceeded(ve) {
                writeProblem(w, http.StatusBadRequest, "BATCH_SIZE_EXCEEDED",
                    "Bad Request", "Размер списка превышает максимум (100 элементов)",
                    traceIDFromCtx(r.Context()))
                return
            }
            writeValidationProblem(w, toViolations(ve), traceIDFromCtx(r.Context()))
            return
        }
        httperr.Write(w, r, err) // не ошибка полей, а сломанный вызов валидатора
        return
    }

    lang := acceptLanguage(r)
    resp := BatchResponse[OrderResponse]{
        Results: make([]BatchResult[OrderResponse], 0, len(req.Items)),
        Summary: BatchSummary{Total: len(req.Items)},
    }

    for _, item := range req.Items {
        order, err := orderService.Create(r.Context(), item.ProductID, item.Quantity)
        if err != nil {
            resp.Results = append(resp.Results, BatchResult[OrderResponse]{
                ID:      item.ProductID,
                Success: false,
                Error: &ItemError{
                    Code:   apperr.CodeOf(err),
                    Detail: localizeError(err, lang),
                },
            })
            resp.Summary.Failed++
            continue
        }
        out := toOrderResponse(order)
        resp.Results = append(resp.Results, BatchResult[OrderResponse]{
            ID:      order.ID,
            Success: true,
            Data:    &out,
        })
        resp.Summary.Success++
    }

    writeJSON(w, http.StatusOK, resp)
}

Цикл for _, item := range req.Items обрабатывает каждый элемент независимо. Ошибка на одном не прерывает обработку остальных. Это верно для доменных отказов — нет товара, неверная цена. Если же упала инфраструктура (база недоступна), продолжать цикл бессмысленно: такую ошибку отдают на весь запрос как 500, иначе клиент получит сто одинаковых «не удалось» и не поймёт, что дело не в данных.

Как выглядит ответ

HTTP/1.1 200 OK
Content-Type: application/json

{
  "results": [
    {
      "id": "ord-001",
      "success": true,
      "data": { "orderId": "ord-001", "status": "NEW", "createdAt": "2026-06-19T08:00:00Z" }
    },
    {
      "id": "prod-bbb",
      "success": false,
      "error": { "code": "INSUFFICIENT_STOCK", "detail": "Товар prod-bbb отсутствует на складе" }
    },
    {
      "id": "ord-003",
      "success": true,
      "data": { "orderId": "ord-003", "status": "NEW", "createdAt": "2026-06-19T08:00:01Z" }
    }
  ],
  "summary": {
    "total": 3,
    "success": 2,
    "failed": 1
  }
}

Статус 200, несмотря на то что один элемент не прошёл. summary позволяет клиенту быстро понять картину без разбора всего списка. Альтернатива — 207 Multi-Status, но его понимают не все клиенты и прокси, поэтому здесь 200 и summary.

Ограничение размера списка

Без верхней границы клиент может прислать тысячи элементов и положить сервер. Ограничение задаётся константой, нарушение — конкретный код ошибки:

const maxBatchSize = 100

func checkBatchSize(size int, w http.ResponseWriter, r *http.Request) bool {
    if size > maxBatchSize {
        writeProblem(w, http.StatusBadRequest, "BATCH_SIZE_EXCEEDED",
            "Bad Request",
            fmt.Sprintf("Размер списка превышает максимум (%d элементов)", maxBatchSize),
            traceIDFromCtx(r.Context()))
        return false
    }
    return true
}

Когда нужна атомарность

Иногда требуется «всё или ничего»: если хоть один элемент не прошёл — отменить всё. Это редкий случай, его нужно явно обозначить в документации эндпоинта. Тогда при любой ошибке возвращается 400, а не 200. По умолчанию — всегда частичный успех.

Долгие задачи: запустить и опросить статус

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

Решение — паттерн polling: сервер сразу отвечает «принял задачу», а клиент периодически спрашивает «как дела?»

Запуск задачи — 202 Accepted

202 Accepted означает «запрос принят, работа ещё идёт». В ответе — идентификатор задачи и ссылка для опроса:

type TaskAccepted struct {
    TaskID    string `json:"taskId"`
    Status    string `json:"status"`    // всегда "PENDING" при создании
    CreatedAt string `json:"createdAt"`
    StatusURL string `json:"statusUrl"`
}

func exportCustomerData(w http.ResponseWriter, r *http.Request) {
    customerID := chi.URLParam(r, "id")

    var req struct {
        Format string `json:"format" validate:"required,oneof=CSV JSON"`
    }
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        httperr.Write(w, r, apperr.NewValidation("invalid request body"))
        return
    }

    taskID, err := exportService.Submit(r.Context(), customerID, req.Format)
    if err != nil {
        httperr.Write(w, r, err)
        return
    }

    statusURL := "/api/v1/tasks/" + taskID
    resp := TaskAccepted{
        TaskID:    taskID,
        Status:    "PENDING",
        CreatedAt: time.Now().UTC().Format(time.RFC3339),
        StatusURL: statusURL,
    }
    w.Header().Set("Location", statusURL)
    writeJSON(w, http.StatusAccepted, resp)
}

Заголовок Location по соглашению указывает на ресурс статуса задачи; автоматически клиенты по нему не переходят — 202 не редирект, — поэтому statusUrl в теле дублирует адрес для тех, кто заголовки не читает.

Опрос статуса — GET /tasks/{id}

type TaskStatus struct {
    TaskID      string     `json:"taskId"`
    Status      string     `json:"status"`
    CreatedAt   string     `json:"createdAt"`
    UpdatedAt   string     `json:"updatedAt"`
    CompletedAt string     `json:"completedAt,omitempty"`
    ResultURL   string     `json:"resultUrl,omitempty"`
    Error       *ItemError `json:"error,omitempty"`
}

func getTaskStatus(w http.ResponseWriter, r *http.Request) {
    taskID := chi.URLParam(r, "id")

    task, err := taskRepo.FindByID(r.Context(), taskID)
    if err != nil {
        httperr.Write(w, r, err)
        return
    }

    resp := TaskStatus{
        TaskID:    task.ID,
        Status:    task.Status,
        CreatedAt: task.CreatedAt.UTC().Format(time.RFC3339),
        UpdatedAt: task.UpdatedAt.UTC().Format(time.RFC3339),
    }

    switch task.Status {
    case "COMPLETED":
        resp.CompletedAt = task.CompletedAt.UTC().Format(time.RFC3339)
        resp.ResultURL = "/api/v1/exports/" + task.ResultID
    case "FAILED":
        resp.CompletedAt = task.CompletedAt.UTC().Format(time.RFC3339)
        resp.Error = &ItemError{
            Code:   task.ErrorCode,
            Detail: task.ErrorDetail,
        }
    case "CANCELLED":
        resp.CompletedAt = task.CompletedAt.UTC().Format(time.RFC3339)
    }

    writeJSON(w, http.StatusOK, resp)
}

omitempty на ResultURL, CompletedAt и Error — эти поля не нужны, пока задача ещё работает. Они появятся в ответе только когда станут актуальными.

Жизненный цикл задачи

Задача проходит через пять статусов:

СтатусЧто значит
PENDINGсоздана, ожидает очереди
RUNNINGвыполняется прямо сейчас
COMPLETEDготово; resultUrl обязателен
FAILEDошибка; error обязателен
CANCELLEDотменена клиентом или системой

Раз статус CANCELLED существует, должен быть и способ в него попасть: POST /tasks/{id}/cancel переводит PENDING или RUNNING в CANCELLED и отвечает 409, если задача уже завершилась.

Пример ответов на разных стадиях:

// Пока работает
{
  "taskId": "550e8400-e29b-41d4-a716-446655440000",
  "status": "RUNNING",
  "createdAt": "2026-06-19T10:30:00Z",
  "updatedAt": "2026-06-19T10:30:05Z"
}

// Готово
{
  "taskId": "550e8400-e29b-41d4-a716-446655440000",
  "status": "COMPLETED",
  "createdAt": "2026-06-19T10:30:00Z",
  "updatedAt": "2026-06-19T10:35:00Z",
  "completedAt": "2026-06-19T10:35:00Z",
  "resultUrl": "/api/v1/exports/550e8400-e29b-41d4-a716-446655440000"
}

// Ошибка
{
  "taskId": "550e8400-e29b-41d4-a716-446655440000",
  "status": "FAILED",
  "createdAt": "2026-06-19T10:30:00Z",
  "updatedAt": "2026-06-19T10:31:00Z",
  "completedAt": "2026-06-19T10:31:00Z",
  "error": {
    "code": "EXPORT_DATA_UNAVAILABLE",
    "detail": "Данные клиента за указанный период отсутствуют"
  }
}

Сервер может добавить заголовок Retry-After с рекомендованным интервалом в секундах:

w.Header().Set("Retry-After", "5")

Типичная практика: 1–5 секунд для коротких задач, 30–60 для длинных.

Локализация сообщений об ошибках

Пользователь видит ошибку и хочет понять её на своём языке. Клиент передаёт предпочтительный язык через заголовок Accept-Language, сервер учитывает его при формировании сообщений.

Читаем язык из запроса

В Go нет никакой магии — заголовок читается явно:

func acceptLanguage(r *http.Request) string {
    lang := r.Header.Get("Accept-Language")
    if lang == "" {
        return "ru"
    }
    parts := strings.Split(lang, ",")
    if len(parts) > 0 {
        tag := strings.TrimSpace(strings.Split(parts[0], ";")[0])
        if tag != "" {
            return tag
        }
    }
    return "ru"
}

Язык по умолчанию — ru. Функция acceptLanguage вызывается явно в каждом обработчике, которому нужна локализация. Никакого глобального состояния или middleware, который незаметно для вас меняет поведение.

Что локализуется, а что нет

Локализуются только те части ответа, которые предназначены для чтения человеком:

  • detail в теле ошибки — читаемое объяснение
  • message в списке нарушений валидации

Остаются на английском всегда:

  • code — машиночитаемый код (ORDER_NOT_FOUND), его используют в switch на клиенте
  • title — стандартное название HTTP-статуса (Bad Request, Not Found)
  • type — URN-идентификатор (urn:problem:order-service:order-not-found)
  • имена JSON-полей — orderId, productId, всегда camelCase латиницей

Логика простая: если что-то читает программа — оно не зависит от языка пользователя.

Функция локализации

func localizeMessage(code, lang string) string {
    msgs := map[string]map[string]string{
        "ORDER_NOT_FOUND": {
            "ru": "Заказ не найден",
            "en": "Order not found",
        },
        "PRODUCT_DISCONTINUED": {
            "ru": "Товар снят с продажи",
            "en": "Product is discontinued",
        },
        "INSUFFICIENT_STOCK": {
            "ru": "Недостаточно товара на складе",
            "en": "Insufficient stock",
        },
    }
    if m, ok := msgs[code]; ok {
        if msg, ok := m[lang]; ok {
            return msg
        }
        if msg, ok := m["ru"]; ok {
            return msg
        }
    }
    return code
}

На практике map заменяют файлами локализации (embed.FS с JSON по языкам) — принцип остаётся тем же. Разбор Accept-Language с весами q= и подбор ближайшего из поддерживаемых языков лучше отдать golang.org/x/text/language: language.ParseAcceptLanguage плюс language.NewMatcher, иначе ru-RU,ru;q=0.9,en;q=0.8 из функции выше прочитается как ru-RU, которого в словаре нет.

Пример: один и тот же запрос на двух языках

живой пример

GET /api/v1/orders/ord-999
Accept-Language: en
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Общий вид ошибки для всех доменов один и тот же:

{
  "type": "urn:problem:order-service:order-not-found",
  "title": "Not Found",
  "status": 404,
  "detail": "Order not found",
  "code": "ORDER_NOT_FOUND"
}

Тот же ответ с доменным кодом - клиенту не нужно разбирать type:

живой пример

GET /api/v1/orders/ord-999
Accept-Language: ru
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Общий вид ошибки для всех доменов один и тот же:

{
  "type": "urn:problem:order-service:order-not-found",
  "title": "Not Found",
  "status": 404,
  "detail": "Заказ не найден",
  "code": "ORDER_NOT_FOUND"
}

code и title одинаковые в обоих ответах. Меняется только detail.

Локализация ошибок валидации

func writeLocalizedValidationProblem(w http.ResponseWriter, errs validator.ValidationErrors, lang, traceID string) {
    violations := make([]Violation, 0, len(errs))
    for _, e := range errs {
        violations = append(violations, Violation{
            Field:   toFieldName(e.Namespace()),
            Code:    strings.ToUpper(e.Tag()),
            Message: localizeValidationMessage(e.Tag(), lang),
        })
    }
    writeValidationProblem(w, violations, traceID)
}

func localizeValidationMessage(tag, lang string) string {
    msgs := map[string]map[string]string{
        "required": {"ru": "Поле обязательно", "en": "Field is required"},
        "min":      {"ru": "Значение слишком мало", "en": "Value is too small"},
        "max":      {"ru": "Значение слишком велико", "en": "Value is too large"},
    }
    if m, ok := msgs[tag]; ok {
        if msg, ok := m[lang]; ok {
            return msg
        }
    }
    return tag
}

Групповая операция с локализацией вместе

Когда нужно совместить — язык читается один раз в начале обработчика и передаётся в localizeMessage параметром:

func batchProcessPayments(w http.ResponseWriter, r *http.Request) {
    lang := acceptLanguage(r)

    var req BatchRequest[PaymentItem]
    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        httperr.Write(w, r, apperr.NewValidation("invalid request body"))
        return
    }
    if !checkBatchSize(len(req.Items), w, r) {
        return
    }

    resp := BatchResponse[PaymentResponse]{
        Results: make([]BatchResult[PaymentResponse], 0, len(req.Items)),
        Summary: BatchSummary{Total: len(req.Items)},
    }

    for _, item := range req.Items {
        payment, err := paymentService.Process(r.Context(), item.AccountID, item.Amount, item.Currency)
        if err != nil {
            resp.Results = append(resp.Results, BatchResult[PaymentResponse]{
                ID:      item.AccountID,
                Success: false,
                Error: &ItemError{
                    Code:   apperr.CodeOf(err),
                    Detail: localizeMessage(apperr.CodeOf(err), lang),
                },
            })
            resp.Summary.Failed++
            continue
        }
        out := toPaymentResponse(payment)
        resp.Results = append(resp.Results, BatchResult[PaymentResponse]{
            ID:      payment.ID,
            Success: true,
            Data:    &out,
        })
        resp.Summary.Success++
    }

    writeJSON(w, http.StatusOK, resp)
}

Коротко

  • Групповая операция — всегда POST /resources/batch. Максимальный размер фиксируется константой, превышение — 400 BATCH_SIZE_EXCEEDED.
  • По умолчанию — частичный успех: каждый элемент обрабатывается независимо, ответ 200 OK даже при частичной ошибке.
  • В ответе всегда есть results (поэлементно) и summary (total/success/failed).
  • Атомарность «всё или ничего» — редкий случай, требует явного объявления.
  • Долгая задача: POST → 202 Accepted + Location + statusUrl. Клиент опрашивает GET /tasks/{id}.
  • Пять статусов задачи: PENDING → RUNNING → COMPLETED / FAILED / CANCELLED.
  • При COMPLETED обязателен resultUrl, при FAILED — error.
  • Локализация читается из Accept-Language явно, без middleware. Язык по умолчанию — ru.
  • Локализуются только человекочитаемые части: detail и message в нарушениях. code, title, type и имена полей — всегда на английском.

Что почитать дальше