Опирается на правила: 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);
}

Не в контроллере (нарушает 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-AfterR-RATE-X1Заголовок обязателен, ставится в ThrottlerGuard
429 без RateLimit-*R-RATE-X1Кастомный ThrottlerGuard добавляет все три заголовка
RateLimit-* только при превышенииR-RATE-2В каждый успешный ответ через кастомный guard
Полагаться на дефолтный @nestjs/throttlerR-RATE-2Расширить ThrottlerGuard, добавить handleRequest
File upload через JSON + Base64R-FILE-2FileInterceptor + multipart/form-data
Скачивание без Content-DispositionR-FILE-5StreamableFile + явный filename в заголовке
Прямое возвращение Buffer из контроллераR-FILE-5StreamableFile — NestJS иначе сериализует как JSON
@ApiOperation({ deprecated: true }) без заголовков Sunset/Deprecation/LinkR-DEP-X1DeprecationInterceptor устанавливает все три
deprecated: true без даты в descriptionR-DEP-1Дата отключения обязательна в description
Молчаливое удаление эндпоинта без 410R-DEP-3Явный 410 Gone с указанием альтернативы

Куда дальше

  • node/errors.md — GoneException, ThrottlerExceptionapplication/problem+json, exceptionFactory для ValidationPipe.
  • node/headers.md — Retry-After, traceparent, кастомные заголовки без X-.
  • node/versioning.md — v1v2, 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 по всему сервису.