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

Три задачи, которые встречаются почти в каждом API: обработать сразу много элементов, запустить операцию которая занимает минуты, и вернуть ошибку на языке пользователя. Разберём каждую по порядку.

Групповые операции: несколько элементов за один запрос

Обычный REST-эндпоинт принимает один элемент и возвращает один результат. Но что делать, когда нужно создать сразу сто заказов или отправить уведомления тысяче пользователей?

Можно послать сто отдельных запросов — но это дорого: сто TCP-соединений, сто раундтрипов, сто раз парсить JSON. Правильное решение — один запрос с массивом элементов, который обрабатывает их все.

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

Эндпоинт для групповой операции выглядит так:

POST /api/v1/orders/batch              ← для коллекции
POST /api/v1/notifications/batch/send  ← для действия над коллекцией

Тело запроса всегда содержит поле items — массив элементов:

export class BatchCreateOrderItemDto {
  @ApiProperty()
  @IsUUID()
  productId: string;

  @ApiProperty()
  @IsInt()
  @Min(1)
  quantity: number;
}

export class BatchCreateOrdersRequestDto {
  @ApiProperty({ type: [BatchCreateOrderItemDto] })
  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => BatchCreateOrderItemDto)
  items: BatchCreateOrderItemDto[];
}

Ответ содержит результат по каждому элементу и общую сводку:

export class BatchItemResultDto {
  @ApiProperty()
  index: number;

  @ApiProperty({ enum: ['SUCCESS', 'ERROR'] })
  status: 'SUCCESS' | 'ERROR';

  orderId?: string;
  error?: { code: string; detail: string };
}

export class BatchSummaryDto {
  @ApiProperty() total: number;
  @ApiProperty() succeeded: number;
  @ApiProperty() failed: number;
}

export class BatchCreateOrdersResponseDto {
  @ApiProperty({ type: [BatchItemResultDto] })
  results: BatchItemResultDto[];

  @ApiProperty()
  summary: BatchSummaryDto;
}

Контроллер

@Post('batch')
@HttpCode(200)
@ApiOperation({ operationId: 'batchCreateOrders', summary: 'Создание заказов группой' })
@ApiResponse({ status: 200, type: BatchCreateOrdersResponseDto })
@ApiResponse({ status: 400, description: 'BATCH_SIZE_EXCEEDED или VALIDATION_ERROR' })
async batchCreate(
  @Body() dto: BatchCreateOrdersRequestDto,
): Promise<BatchCreateOrdersResponseDto> {
  return this.ordersService.batchCreate(dto.items);
}

@HttpCode(200) обязателен — без него NestJS автоматически возвращает 201 для любого @Post.

Частичный успех

Важная идея: если один из элементов содержит ошибку, это не должно отменять остальные. Каждый элемент обрабатывается независимо, результат фиксируется в поле status:

POST /api/v1/orders/batch

{
  "items": [
    { "productId": "c9f3...", "quantity": 2 },
    { "productId": "d1a8...", "quantity": 0 },
    { "productId": "e7b2...", "quantity": 1 }
  ]
}
HTTP/1.1 200 OK

{
  "results": [
    { "index": 0, "status": "SUCCESS", "orderId": "550e..." },
    { "index": 1, "status": "ERROR", "error": { "code": "INVALID_QUANTITY", "detail": "Количество должно быть больше нуля" } },
    { "index": 2, "status": "SUCCESS", "orderId": "6ba7..." }
  ],
  "summary": {
    "total": 3,
    "succeeded": 2,
    "failed": 1
  }
}

Статус ответа — 200 OK даже при частичных ошибках. 4xx здесь неуместен: HTTP-запрос выполнился успешно, просто часть элементов не прошла бизнес-валидацию. index — позиция элемента в исходном массиве, начиная с нуля.

Атомарность (всё или ничего) — не поведение по умолчанию. Если она нужна — это надо явно указать в документации эндпоинта:

@ApiOperation({
  summary: 'Атомарное резервирование позиций на складе',
  description: 'Все позиции обрабатываются в одной транзакции. Ошибка любой позиции — откат всех.',
})

Ограничение размера

Принимать неограниченное количество элементов опасно. Нужно установить максимум и явно сообщать о превышении:

const MAX_BATCH_SIZE = 100;

async batchCreate(items: BatchCreateOrderItemDto[]): Promise<BatchCreateOrdersResponseDto> {
  if (items.length > MAX_BATCH_SIZE) {
    throw new BadRequestException({
      type: 'urn:problem:order-service:batch-size-exceeded',
      status: 400,
      title: 'Bad Request',
      detail: `Размер группы превышает максимум (${MAX_BATCH_SIZE} элементов)`,
      code: 'BATCH_SIZE_EXCEEDED',
    });
  }
  // ...
}

Это исключение попадёт в HttpExceptionFilter, который выставит Content-Type: application/problem+json.

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

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

Стандартное решение: запустить операцию и сразу вернуть ответ, а клиент периодически спрашивает «ну как там?» пока не получит финальный статус.

Запуск операции

export class GenerateReportRequestDto {
  @ApiProperty({ example: '2026-01-01' })
  @IsDateString()
  dateFrom: string;

  @ApiProperty({ example: '2026-12-31' })
  @IsDateString()
  dateTo: string;
}

export class AsyncTaskResponseDto {
  @ApiProperty() taskId: string;
  @ApiProperty({ enum: ['PENDING', 'PROCESSING', 'COMPLETED', 'FAILED'] })
  status: string;
  @ApiProperty() createdAt: string;
  @ApiProperty() statusUrl: string;
}
@Post('generate')
@HttpCode(202)
@ApiResponse({ status: 202, type: AsyncTaskResponseDto, headers: { Location: { description: 'URL задачи' } } })
async generate(
  @Body() dto: GenerateReportRequestDto,
  @Res({ passthrough: true }) res: Response,
): Promise<AsyncTaskResponseDto> {
  const task = await this.reportsService.startGeneration(dto);
  const statusUrl = `/api/v1/tasks/${task.taskId}`;
  res.location(statusUrl);
  return {
    taskId: task.taskId,
    status: 'PENDING',
    createdAt: task.createdAt,
    statusUrl,
  };
}

@Res({ passthrough: true }) позволяет одновременно установить заголовок Location и вернуть тело через return. Без passthrough: true NestJS передаёт управление ответом полностью в Express и return перестаёт работать.

Ответ на запуск:

POST /api/v1/reports/generate

{ "dateFrom": "2026-01-01", "dateTo": "2026-12-31" }

HTTP/1.1 202 Accepted
Location: /api/v1/tasks/550e8400-e29b-41d4-a716-446655440000

{
  "taskId": "550e8400-e29b-41d4-a716-446655440000",
  "status": "PENDING",
  "createdAt": "2026-06-19T10:30:00Z",
  "statusUrl": "/api/v1/tasks/550e8400-e29b-41d4-a716-446655440000"
}

202 Accepted означает «принято к обработке, но ещё не выполнено». Заголовок Location указывает, куда ходить за статусом.

Опрос статуса

Клиент периодически делает GET по URL из Location или statusUrl:

@Controller('tasks')
export class TasksController {
  @Get(':taskId')
  getTask(@Param('taskId', ParseUUIDPipe) taskId: string): Promise<TaskStatusResponseDto> {
    return this.tasksService.getTask(taskId);
  }
}

Возможные ответы в зависимости от состояния:

В процессе:
{
  "taskId": "550e8400-...",
  "status": "PROCESSING",
  "progress": 45,
  "createdAt": "2026-06-19T10:30:00Z"
}

Завершено:
{
  "taskId": "550e8400-...",
  "status": "COMPLETED",
  "progress": 100,
  "createdAt": "2026-06-19T10:30:00Z",
  "completedAt": "2026-06-19T10:35:00Z",
  "resultUrl": "/api/v1/reports/550e8400-..."
}

Ошибка:
{
  "taskId": "550e8400-...",
  "status": "FAILED",
  "createdAt": "2026-06-19T10:30:00Z",
  "completedAt": "2026-06-19T10:32:00Z",
  "error": {
    "code": "REPORT_GENERATION_FAILED",
    "detail": "Данные за указанный период отсутствуют"
  }
}

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

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

Как часто опрашивать — решает клиент. Сервер может подсказать интервал через заголовок Retry-After.

Локализация: сообщения об ошибках на языке пользователя

Если API используют клиенты из разных стран, сообщения об ошибках удобнее получать на родном языке. Для этого HTTP предусматривает заголовок Accept-Language — клиент указывает предпочитаемый язык, сервер отвечает на нём.

Подключение nestjs-i18n

import { I18nModule, AcceptLanguageResolver } from 'nestjs-i18n';

@Module({
  imports: [
    I18nModule.forRoot({
      fallbackLanguage: 'ru',
      loaderOptions: { path: join(__dirname, '/i18n/'), watch: true },
      resolvers: [AcceptLanguageResolver],
    }),
  ],
})
export class AppModule {}

AcceptLanguageResolver автоматически читает заголовок Accept-Language из каждого запроса. Если заголовок отсутствует или язык неизвестен — используется fallbackLanguage.

Локализация текста ошибки

@Catch(OrderNotFoundException)
export class OrderNotFoundFilter implements ExceptionFilter {
  constructor(private readonly i18n: I18nService) {}

  catch(exception: OrderNotFoundException, host: ArgumentsHost): void {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const lang = request.headers['accept-language'] ?? 'ru';

    const detail = this.i18n.t('errors.ORDER_NOT_FOUND', { lang });

    response.status(404).contentType('application/problem+json').json({
      type: 'urn:problem:order-service:order-not-found',
      status: 404,
      title: 'Not Found',
      detail,
      code: 'ORDER_NOT_FOUND',
    });
  }
}

Файлы переводов лежат рядом:

// i18n/ru/errors.json
{ "ORDER_NOT_FOUND": "Заказ не найден" }

// i18n/en/errors.json
{ "ORDER_NOT_FOUND": "Order not found" }

Локализация сообщений валидации

app.useGlobalPipes(
  new ValidationPipe({
    transform: true,
    exceptionFactory: (errors) => {
      const violations = errors.map((e) => ({
        field: e.property,
        message: Object.values(e.constraints ?? {})[0] ?? '',
      }));
      return new HttpException(
        {
          type: 'urn:problem:order-service:validation-error',
          status: 400,
          title: 'Bad Request',
          detail: 'Ошибка валидации входных данных',
          code: 'VALIDATION_ERROR',
          violations,
        },
        400,
      );
    },
  }),
);

nestjs-i18n предоставляет декоратор @i18nValidate* для локализации сообщений class-validator. Поле message в violations локализуется, поле code — нет.

Что локализировать, а что нет

Локализуется только то, что видит пользователь: поля detail и violations[].message.

Поля code, title, type — машиночитаемые идентификаторы. Клиентский код делает switch (error.code) — им всё равно на язык пользователя, они должны быть стабильными. Их не локализуют.

Частая ошибка — перевести код ошибки или URI:

// Неправильно
{ "code": "ЗАКАЗ_НЕ_НАЙДЕН" }
{ "type": "urn:problem:order-service:заказ-не-найден" }

// Правильно
{ "code": "ORDER_NOT_FOUND", "detail": "Заказ не найден" }
{ "type": "urn:problem:order-service:order-not-found" }

Имена JSON-полей тоже не локализируют — они часть контракта API.

Коротко

  • Групповые операции: POST /resources/batch, тело { items: [...] }, ответ { results, summary }, статус 200 OK.
  • Каждый элемент обрабатывается независимо: ошибка одного не отменяет остальные. Это поведение по умолчанию; атомарность декларируется явно.
  • @HttpCode(200) обязателен на групповом @Post — иначе NestJS вернёт 201.
  • Установить максимальный размер группы и возвращать BATCH_SIZE_EXCEEDED при превышении.
  • Долгие операции: 202 Accepted + заголовок Location + тело с taskId и statusUrl.
  • Статусы задачи: PENDINGPROCESSINGCOMPLETEDresultUrl) или FAILEDerror).
  • @Res({ passthrough: true }) — чтобы одновременно установить заголовок и вернуть тело через return.
  • Локализация через nestjs-i18n + AcceptLanguageResolver: читает Accept-Language, отдаёт текст на нужном языке.
  • Локализуются только detail и violations[].message. Коды, URI, имена полей — всегда на английском.

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

  • Ошибки RFC 9457 — ProblemDetails через Exception Filters, exceptionFactory для ValidationPipe.
  • Заголовки и трассировка — Idempotency-Key для групповых операций, Location и traceparent.
  • JSON и формат ответов — content + метаданные пагинации, undefined вместо null.
  • Rate limiting, файлы, deprecation — @nestjs/throttler, StreamableFile, Sunset.