Опирается на правила: R-BATCH-1..5, R-ASYNC-1..4, R-LOC-1..3 и X-коды из REST API Style Guide → раздел Batch, async, локализация.

Важно знать

  • Batch обрабатывается поэлементно (partial success). Атомарность объявляется явно, не по умолчанию.
  • POST /resources/batch или POST /resources/batch/<action>. Метод всегда POST.
  • Request: {"items": [...]}. Response: 200 OK + results + summary (total/success/failed).
  • В Go BatchResponse[T any] — дженерик-структура; компилятор гарантирует согласованность типов.
  • Async через polling: 202 Accepted + Location + taskId + statusUrl.
  • Task statuses: PENDING / RUNNING / COMPLETED / FAILED; при COMPLETEDresultUrl обязателен, при FAILEDerror обязателен.
  • Локализация: Accept-Languagedetail, violations[].message. Читается явно — никакой middleware-магии.
  • Не локализуются: code, title, type, имена JSON-полей — они машиночитаемы.

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

Batch-операции

R-BATCH-1..5: partial success, единый эндпоинт, ограничение размера.

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

В Go batch-контракт выражается через дженерик-структуры, что позволяет переиспользовать их в разных доменах:

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, не полный ProblemDetails. Ошибка относится к элементу, а не к HTTP-запросу — полный Problem Details здесь избыточен.

Endpoint и роутинг

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", "Размер batch превышает максимум (100 элементов)",
                    traceIDFromCtx(r.Context()))
                return
            }
            writeValidationProblem(w, toViolations(ve), traceIDFromCtx(r.Context()))
            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
        }
        resp.Results = append(resp.Results, BatchResult[OrderResponse]{
            ID:      order.ID,
            Success: true,
            Data:    toOrderResponse(order),
        })
        resp.Summary.Success++
    }

    writeJSON(w, http.StatusOK, resp)
}

Код возврата — 200 OK даже при частичной ошибке: это partial success, не провал всего запроса (R-BATCH-3).

Размер batch — проверка и ошибка

R-BATCH-4..5: максимум задаётся константой, превышение → 400 BATCH_SIZE_EXCEEDED.

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("Размер batch превышает максимум (%d элементов)", maxBatchSize),
            traceIDFromCtx(r.Context()))
        return false
    }
    return true
}

Ответ при превышении:

{
  "type": "urn:problem:order-service:batch-size-exceeded",
  "title": "Bad Request",
  "status": 400,
  "detail": "Размер batch превышает максимум (100 элементов)",
  "code": "BATCH_SIZE_EXCEEDED",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}

Ответ — partial success

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
  }
}

Атомарный batch

Если нужна атомарность (all-or-nothing), это указывается явно в документации эндпоинта — и тогда при любой ошибке возвращается 400 (не 200) с указанием failed-элементов. Но дефолт — partial success.

Async-операции

R-ASYNC-1..4: паттерн polling.

Для операций, которые не укладываются во время одного HTTP-запроса: генерация отчёта, пакетная перекодировка, экспорт данных клиента.

Структуры задачи

type TaskAccepted struct {
    TaskID    string `json:"taskId"`
    Status    string `json:"status"`    // "PENDING"
    CreatedAt string `json:"createdAt"` // ISO 8601
    StatusURL string `json:"statusUrl"`
}

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

ResultURL и CompletedAtomitempty: они отсутствуют в ответе пока задача не завершена (R-RSP-X1).

Submit — 202 Accepted

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 — обязателен (R-ASYNC-1). statusUrl в body дублирует его для клиентов, не читающих заголовки.

Polling — GET /tasks/{id}

r.Get("/api/v1/tasks/{id}", getTaskStatus)

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,
        }
    }

    writeJSON(w, http.StatusOK, resp)
}

Статусы задачи

R-ASYNC-3..4:

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

Ответы по фазам:

// RUNNING
{
  "taskId": "550e8400-e29b-41d4-a716-446655440000",
  "status": "RUNNING",
  "createdAt": "2026-06-19T10:30:00Z",
  "updatedAt": "2026-06-19T10:30:05Z"
}

// COMPLETED
{
  "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"
}

// FAILED
{
  "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": "Данные клиента за указанный период отсутствуют"
  }
}

Рекомендуемый интервал polling

Сервер может подсказать клиенту через Retry-After:

// При PENDING/RUNNING можно добавить подсказку:
w.Header().Set("Retry-After", "5") // секунд до следующего опроса

По умолчанию: 1–5 секунд для коротких задач, 30–60 для длинных — решение за клиентом.

Локализация

R-LOC-1..3: через заголовок Accept-Language.

В Go локаль читается явно из заголовка — нет ThreadLocal, нет магического бина:

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 (R-LOC-2). acceptLanguage вызывается явно в каждом хендлере, где нужна локализация.

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

R-LOC-3:

  • detail в ProblemDetails.
  • message в violations.
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-файлами по языкам), но принцип остаётся тем же.

Пример ответа с локализацией

GET /api/v1/orders/ord-999
Accept-Language: en
HTTP/1.1 404 Not Found
Content-Type: application/problem+json

{
  "type": "urn:problem:order-service:order-not-found",
  "title": "Not Found",
  "status": 404,
  "detail": "Order not found",
  "code": "ORDER_NOT_FOUND",
  "traceId": "4bf92f3577b34da6a3ce929d0e0e4736"
}
GET /api/v1/orders/ord-999
Accept-Language: ru
HTTP/1.1 404 Not Found
Content-Type: application/problem+json

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

code и title — неизменны. detail — локализован.

Локализация в violations

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
}

Что НЕ локализуется

R-LOC-X1:

  • code — всегда UPPER_SNAKE_CASE на английском (ORDER_NOT_FOUND, не ЗАКАЗ_НЕ_НАЙДЕН).
  • title — стандартное название HTTP-статуса (Bad Request, Not Found).
  • type — URN-идентификатор (urn:problem:order-service:order-not-found).
  • Имена JSON-полей — orderId, productId, не идЗаказа.

Логика: code используется клиентским кодом в switch-выражениях — он не должен зависеть от языка пользователя.

Интеграция со Sber-доменом

Пример хендлера, объединяющего batch + локализацию:

type SberPaymentItem struct {
    AccountID string `json:"accountId" validate:"required"`
    Amount    int64  `json:"amount"    validate:"required,min=1"`
    Currency  string `json:"currency"  validate:"required,oneof=RUB USD EUR"`
}

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

    var req BatchRequest[SberPaymentItem]
    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
        }
        resp.Results = append(resp.Results, BatchResult[PaymentResponse]{
            ID:      payment.ID,
            Success: true,
            Data:    toPaymentResponse(payment),
        })
        resp.Summary.Success++
    }

    writeJSON(w, http.StatusOK, resp)
}

lang извлекается один раз в начале хендлера и передаётся в localizeMessage — без глобального состояния.

Что запрещено

АнтипаттернПравилоЧто взамен
Batch atomicity по умолчаниюR-BATCH-3partial success; атомарность объявляется явно
4xx на partial failureR-BATCH-3200 OK + per-item success: false
Batch без summaryR-BATCH-3total, success, failed
Batch без документированного max-sizeR-BATCH-4явная константа + 400 BATCH_SIZE_EXCEEDED
null-поля в BatchResultR-RSP-X1omitempty на data и error
Async 201 Created вместо 202 AcceptedR-RSP-6 / R-ASYNC-1202 Accepted
Async без Location headerR-ASYNC-1w.Header().Set("Location", statusURL)
COMPLETED без resultUrlR-ASYNC-4обязателен
FAILED без errorR-ASYNC-4обязателен
code: "ЗАКАЗ_НЕ_НАЙДЕН" (русский enum)R-LOC-X1ORDER_NOT_FOUND
title на русскомR-LOC-X1стандартное название HTTP-статуса на английском
JSON-поля на кириллицеR-LOC-X1camelCase латиницей
Accept-Language проигнорированR-LOC-1явный вызов acceptLanguage(r)
Глобальный язык через context.WithValue без явного ключапередавать lang string параметром

Куда дальше

  • go/errors.md — ProblemDetails, violations, code, detail, type: полная реализация в Go.
  • go/headers.md — Idempotency-Key для batch-запросов, Location для async, traceparent.
  • go/json-and-responses.md — content + метаданные пагинации, omitempty.
  • go/rate-limiting-files-deprecation.md — 429 Too Many Requests рядом с batch-эндпоинтами.
  • go/url-and-resources.md — /batch как суффикс коллекции, вложенность ресурсов.
  • Resilience → async polling — реализация task-queue и горутин на стороне сервера.