Опирается на правила:
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; приCOMPLETED—resultUrlобязателен, приFAILED—errorобязателен.- Локализация:
Accept-Language→detail,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 и CompletedAt — omitempty: они отсутствуют в ответе пока задача не завершена (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-3 | partial success; атомарность объявляется явно |
4xx на partial failure | R-BATCH-3 | 200 OK + per-item success: false |
Batch без summary | R-BATCH-3 | total, success, failed |
| Batch без документированного max-size | R-BATCH-4 | явная константа + 400 BATCH_SIZE_EXCEEDED |
null-поля в BatchResult | R-RSP-X1 | omitempty на data и error |
Async 201 Created вместо 202 Accepted | R-RSP-6 / R-ASYNC-1 | 202 Accepted |
Async без Location header | R-ASYNC-1 | w.Header().Set("Location", statusURL) |
COMPLETED без resultUrl | R-ASYNC-4 | обязателен |
FAILED без error | R-ASYNC-4 | обязателен |
code: "ЗАКАЗ_НЕ_НАЙДЕН" (русский enum) | R-LOC-X1 | ORDER_NOT_FOUND |
title на русском | R-LOC-X1 | стандартное название HTTP-статуса на английском |
| JSON-поля на кириллице | R-LOC-X1 | camelCase латиницей |
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 и горутин на стороне сервера.