Три задачи, которые встречаются почти в каждом 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. - Статусы задачи:
PENDING→PROCESSING→COMPLETED(сresultUrl) илиFAILED(сerror). @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.