Опирается на правила:
R-RATE-1..3,R-RATE-X1,R-FILE-1..5,R-DEP-1..3,R-DEP-X1,R-ERR-1..4— язык-нейтральный контракт → раздел Rate limiting, файлы, deprecation.
Важно знать
- 429 возвращает
Retry-After(секунд до сброса) +RateLimit-Limit/Remaining/Resetв теле — RFC 9457.RateLimit-*включаются в каждый успешный ответ, не только при превышении (R-RATE-2).@nestjs/throttlerпо умолчанию не шлётRateLimit-*— нужен кастомныйThrottlerGuard.- Файлы загружаются через
@UseInterceptors(FileInterceptor('file'))+multipart/form-data; не Base64 в JSON.- Скачивание —
StreamableFile+Content-Disposition: attachment; filename="...".- Deprecation —
@ApiOperation({ deprecated: true })+ interceptor с заголовкамиSunset,Deprecation,Link.deprecated: trueбезSunsetзапрещено: клиент не знает, когда мигрировать (R-DEP-X1).- После
Sunset— эндпоинт возвращает410 Goneс указанием альтернативы.
Три темы объединены одним разделом контракта: rate limiting защищает сервис от перегрузки, файловый transport принципиально отличается от JSON (бинарный, потоковый), deprecation — управляемый переход от одной версии к другой.
Rate limiting
R-RATE-1..3: при превышении — 429; в каждый ответ — RateLimit-*.
Кастомный ThrottlerGuard с RateLimit-* заголовками
@nestjs/throttler из коробки шлёт только 429 Too Many Requests без заголовков RateLimit-*. Расширяем:
import { ThrottlerGuard, ThrottlerException } from '@nestjs/throttler';
import { ExecutionContext, Injectable } from '@nestjs/common';
import { Request, Response } from 'express';
@Injectable()
export class RateLimitGuard extends ThrottlerGuard {
protected async handleRequest(
context: ExecutionContext,
limit: number,
ttl: number,
): Promise<boolean> {
const res: Response = context.switchToHttp().getResponse();
const key = this.generateKey(context, '', '');
const { totalHits } = await this.storageService.increment(key, ttl);
const remaining = Math.max(0, limit - totalHits);
const reset = Math.floor(Date.now() / 1000) + ttl;
res.setHeader('RateLimit-Limit', limit);
res.setHeader('RateLimit-Remaining', remaining);
res.setHeader('RateLimit-Reset', reset);
if (totalHits > limit) {
res.setHeader('Retry-After', ttl);
throw new ThrottlerException();
}
return true;
}
}
Регистрация глобально или на конкретном контроллере:
@Module({
imports: [
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
],
providers: [{ provide: APP_GUARD, useClass: RateLimitGuard }],
})
export class AppModule {}
429 — формат ответа
R-RATE-1 + R-ERR-1..4: ответ — RFC 9457, Content-Type: application/problem+json.
Exception Filter перехватывает ThrottlerException:
import { ExceptionFilter, Catch, ArgumentsHost } from '@nestjs/common';
import { ThrottlerException } from '@nestjs/throttler';
import { Response } from 'express';
@Catch(ThrottlerException)
export class ThrottlerExceptionFilter implements ExceptionFilter {
catch(_exception: ThrottlerException, host: ArgumentsHost): void {
const res: Response = host.switchToHttp().getResponse<Response>();
const retryAfter = res.getHeader('Retry-After') ?? 60;
res
.status(429)
.setHeader('Content-Type', 'application/problem+json')
.json({
type: 'urn:problem:order-service:rate-limit-exceeded',
status: 429,
title: 'Too Many Requests',
detail: `Превышен лимит запросов. Повторите через ${retryAfter} секунд.`,
code: 'RATE_LIMIT_EXCEEDED',
});
}
}
Успешный ответ — RateLimit-* в заголовках
HTTP/1.1 200 OK
RateLimit-Limit: 100
RateLimit-Remaining: 43
RateLimit-Reset: 1719849600
Content-Type: application/json
{ "orderId": "ord-7821", "status": "CONFIRMED" }
RateLimit-Limit— максимум запросов в окне (ttl).RateLimit-Remaining— сколько осталось до сброса.RateLimit-Reset— Unix timestamp сброса окна.
Клиент видит сколько ещё запросов доступно и может замедлиться превентивно.
OpenAPI — описание 429
R-RATE-3: 429 указывается в спецификации каждого эндпоинта с rate limiting.
@ApiResponse({
status: 429,
description: 'Too Many Requests',
headers: {
'Retry-After': {
schema: { type: 'integer' },
description: 'Секунд до сброса лимита',
},
'RateLimit-Limit': { schema: { type: 'integer' } },
'RateLimit-Remaining': { schema: { type: 'integer' } },
'RateLimit-Reset': {
schema: { type: 'integer' },
description: 'Unix timestamp сброса окна',
},
},
schema: { $ref: getSchemaPath(ProblemDetailsDto) },
})
@UseGuards(RateLimitGuard)
@Get()
async getOrders(): Promise<OrdersPageDto> { ... }
Загрузка файлов
R-FILE-1..5: файлы как вложенный ресурс через POST multipart/form-data.
Endpoint и структура
POST /api/v1/orders/{orderId}/attachments ← вложение к заказу
POST /api/v1/customers/me/avatar ← avatar singleton
Файл — вложенный ресурс (attachments к order, avatar к customer/me). Не самостоятельный /files/.
Контроллер загрузки
import {
Controller,
Post,
Param,
UploadedFile,
UseInterceptors,
ParseUUIDPipe,
HttpCode,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import {
ApiConsumes,
ApiBody,
ApiResponse,
ApiTags,
ApiOperation,
} from '@nestjs/swagger';
@ApiTags('Orders')
@Controller('orders')
export class OrderAttachmentsController {
constructor(private readonly attachmentsService: OrderAttachmentsService) {}
@Post(':orderId/attachments')
@ApiOperation({ operationId: 'uploadOrderAttachment', summary: 'Загрузить вложение к заказу' })
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
required: ['file'],
properties: {
file: {
type: 'string',
format: 'binary',
description: 'Максимум 10 МБ. Допустимые типы: PDF, PNG, JPG',
},
description: {
type: 'string',
maxLength: 500,
},
},
},
})
@ApiResponse({ status: 201, type: AttachmentDto })
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
const allowed = ['application/pdf', 'image/png', 'image/jpeg'];
cb(null, allowed.includes(file.mimetype));
},
}),
)
async upload(
@Param('orderId', ParseUUIDPipe) orderId: string,
@UploadedFile() file: Express.Multer.File,
@Body() body: UploadAttachmentDto,
): Promise<AttachmentDto> {
return this.attachmentsService.upload(orderId, file, body.description);
}
}
@HttpCode не нужен для @Post при создании — NestJS возвращает 201 по умолчанию (R-RSP-3).
Request — multipart/form-data
POST /api/v1/orders/ord-7821/attachments
Content-Type: multipart/form-data; boundary=----Boundary
------Boundary
Content-Disposition: form-data; name="file"; filename="invoice.pdf"
Content-Type: application/pdf
<binary data>
------Boundary
Content-Disposition: form-data; name="description"
Счёт-фактура за март 2026
------Boundary--
Не Base64 в JSON — раздувает размер на 33% и делает payload нестримируемым.
Response — 201 + метаданные
{
"attachmentId": "a3f1e200-12cd-4567-89ab-cdef01234567",
"fileName": "invoice.pdf",
"contentType": "application/pdf",
"size": 204800,
"uploadedAt": "2026-06-19T09:00:00Z"
}
Location: /api/v1/orders/ord-7821/attachments/a3f1e200-... — устанавливаем через @Res({ passthrough: true }) или interceptor:
@Post(':orderId/attachments')
async upload(
@Param('orderId', ParseUUIDPipe) orderId: string,
@UploadedFile() file: Express.Multer.File,
@Res({ passthrough: true }) res: Response,
): Promise<AttachmentDto> {
const result = await this.attachmentsService.upload(orderId, file);
res.location(`/api/v1/orders/${orderId}/attachments/${result.attachmentId}`);
return result;
}
Скачивание — StreamableFile + Content-Disposition
R-FILE-5: GET с бинарным Content-Type и Content-Disposition.
@ApiTags('Orders')
@Controller('orders')
export class OrderAttachmentsController {
@Get(':orderId/attachments/:attachmentId')
@ApiOperation({ operationId: 'downloadOrderAttachment', summary: 'Скачать вложение заказа' })
@ApiProduces('application/octet-stream')
async download(
@Param('orderId', ParseUUIDPipe) orderId: string,
@Param('attachmentId', ParseUUIDPipe) attachmentId: string,
@Res({ passthrough: true }) res: Response,
): Promise<StreamableFile> {
const { stream, metadata } = await this.attachmentsService.download(
orderId,
attachmentId,
);
res.setHeader('Content-Type', metadata.contentType);
res.setHeader(
'Content-Disposition',
`attachment; filename="${metadata.fileName}"`,
);
res.setHeader('Content-Length', metadata.size);
return new StreamableFile(stream);
}
}
StreamableFile — встроенный механизм NestJS для потоковой отдачи файлов. Без StreamableFile NestJS пытается сериализовать ответ как JSON.
Content-Disposition: attachment; filename="..." — браузер сохраняет файл с правильным именем. Без него сохраняется как download без расширения.
DTO загруженного файла
export class AttachmentDto {
@ApiProperty()
attachmentId: string;
@ApiProperty()
fileName: string;
@ApiProperty()
contentType: string;
@ApiProperty({ description: 'Размер файла в байтах' })
size: number;
@ApiProperty({ type: String, format: 'date-time' })
uploadedAt: Date;
description?: string;
}
description — опциональное поле (?): при отсутствии выпадает из JSON (R-RSP-X1 — нет null в 2xx).
Deprecation
R-DEP-1..3: управляемое снятие эндпоинта — пометить, уведомить, мониторить, закрыть.
1. Пометить в OpenAPI
@Get(':orderId/status')
@ApiOperation({
operationId: 'getOrderStatus',
summary: 'Получить статус заказа',
deprecated: true,
description:
'DEPRECATED: используйте GET /api/v2/orders/{orderId}. Будет удалён после 2026-12-01.',
})
@ApiResponse({ status: 200, type: OrderStatusDto })
async getStatus(
@Param('orderId', ParseUUIDPipe) orderId: string,
): Promise<OrderStatusDto> {
return this.ordersService.getStatus(orderId);
}
2. Заголовки Sunset/Deprecation/Link — через interceptor
Не в контроллере (нарушает SRP) — отдельный interceptor, настраиваемый через декоратор:
import { SetMetadata } from '@nestjs/common';
export const SUNSET_KEY = 'sunset';
export interface SunsetOptions {
date: string;
successor: string;
}
export const Sunset = (options: SunsetOptions) =>
SetMetadata(SUNSET_KEY, options);
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable } from 'rxjs';
import { tap } from 'rxjs/operators';
import { Response } from 'express';
import { SUNSET_KEY, SunsetOptions } from './sunset.decorator';
@Injectable()
export class DeprecationInterceptor implements NestInterceptor {
constructor(private readonly reflector: Reflector) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const options = this.reflector.get<SunsetOptions>(
SUNSET_KEY,
context.getHandler(),
);
if (!options) {
return next.handle();
}
const res: Response = context.switchToHttp().getResponse();
return next.handle().pipe(
tap(() => {
res.setHeader('Sunset', new Date(options.date).toUTCString());
res.setHeader('Deprecation', 'true');
res.setHeader(
'Link',
`<${options.successor}>; rel="successor-version"`,
);
}),
);
}
}
Применение:
@Get(':orderId/status')
@ApiOperation({ deprecated: true, ... })
@Sunset({
date: '2026-12-01',
successor: '/api/v2/orders/{orderId}',
})
@UseInterceptors(DeprecationInterceptor)
async getStatus(...): Promise<OrderStatusDto> { ... }
Response headers:
HTTP/1.1 200 OK
Sunset: Tue, 01 Dec 2026 00:00:00 GMT
Deprecation: true
Link: </api/v2/orders/{orderId}>; rel="successor-version"
3. Мониторинг трафика на deprecated эндпоинты
Перед отключением убеждаемся, что трафик близок к нулю. Interceptor логирует каждый вызов:
tap(() => {
this.logger.warn(
`Deprecated endpoint called: ${context.switchToHttp().getRequest().path}`,
);
})
4. 410 Gone после Sunset
После даты отключения эндпоинт явно возвращает 410:
@Get(':orderId/status')
@ApiOperation({ summary: 'Получить статус заказа (удалён)', deprecated: true })
@ApiResponse({ status: 410 })
@HttpCode(410)
async getStatusRemoved(): Promise<never> {
throw new GoneException({
type: 'urn:problem:order-service:endpoint-removed',
status: 410,
title: 'Gone',
detail: 'Эндпоинт удалён. Используйте GET /api/v2/orders/{orderId}.',
code: 'ENDPOINT_REMOVED',
});
}
GoneException — встроенный в NestJS (HttpException с кодом 410). Exception Filter транслирует в application/problem+json (R-ERR-3).
Период между deprecation и Sunset — не менее 6 месяцев. Крупные клиенты (Sber, корпоративные интеграции) требуют квартального цикла планирования.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
429 без Retry-After | R-RATE-X1 | Заголовок обязателен, ставится в ThrottlerGuard |
429 без RateLimit-* | R-RATE-X1 | Кастомный ThrottlerGuard добавляет все три заголовка |
RateLimit-* только при превышении | R-RATE-2 | В каждый успешный ответ через кастомный guard |
Полагаться на дефолтный @nestjs/throttler | R-RATE-2 | Расширить ThrottlerGuard, добавить handleRequest |
| File upload через JSON + Base64 | R-FILE-2 | FileInterceptor + multipart/form-data |
Скачивание без Content-Disposition | R-FILE-5 | StreamableFile + явный filename в заголовке |
Прямое возвращение Buffer из контроллера | R-FILE-5 | StreamableFile — NestJS иначе сериализует как JSON |
@ApiOperation({ deprecated: true }) без заголовков Sunset/Deprecation/Link | R-DEP-X1 | DeprecationInterceptor устанавливает все три |
deprecated: true без даты в description | R-DEP-1 | Дата отключения обязательна в description |
Молчаливое удаление эндпоинта без 410 | R-DEP-3 | Явный 410 Gone с указанием альтернативы |
Куда дальше
- node/errors.md —
GoneException,ThrottlerException→application/problem+json,exceptionFactoryдляValidationPipe. - node/headers.md —
Retry-After,traceparent, кастомные заголовки безX-. - node/versioning.md —
v1→v2, URI-версионирование в NestJS, deprecation цикл. - node/json-and-responses.md —
AttachmentDto,undefinedвместоnull,R-RSP-X1. - node/batch-async-localization.md —
202 Acceptedдля долгих загрузок. - node/alias-and-actions.md — action-эндпоинты рядом с файловыми ресурсами.
- node/url-and-resources.md —
attachmentsкак вложенный ресурс,meв/customers/me/avatar. - node/query-params.md — фильтрация списка вложений по типу/дате.
- node/openapi-and-antipatterns.md —
@ApiConsumes,@ApiBodyдля multipart;operationIdна каждом маршруте. - Смежный раздел: error-handling/node — глобальные Exception Filters,
problem+jsonпо всему сервису.