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

Три темы, которые регулярно возникают в реальных API: как ограничить количество запросов, как принимать и отдавать файлы, и как аккуратно отключить старый эндпоинт, не ломая клиентов.

Rate limiting: почему 429 без заголовков — это полбеды

Когда клиент слишком часто обращается к API, сервер возвращает 429 Too Many Requests. Это правильно. Но клиенту нужно знать не только «ты превысил лимит», но и когда можно попробовать снова и сколько запросов ещё осталось в текущем окне. Для этого существуют три заголовка:

  • RateLimit-Limit — максимум запросов в окне (например, 100 в минуту).
  • RateLimit-Remaining — сколько запросов ещё доступно до сброса.
  • RateLimit-Reset — когда сбросится счётчик (Unix timestamp).

Важный момент: эти заголовки отправляются в каждый успешный ответ, не только при превышении. Так клиент видит состояние лимита заранее и может сам замедлиться.

При превышении добавляется ещё один заголовок — Retry-After (секунд до сброса).

Проблема с @nestjs/throttler по умолчанию

Пакет @nestjs/throttler из коробки возвращает 429, но не добавляет заголовки RateLimit-*. Нужно расширить стандартный ThrottlerGuard:

import { ThrottlerGuard, ThrottlerException, ThrottlerRequest } from '@nestjs/throttler';
import { Injectable } from '@nestjs/common';
import { Response } from 'express';

@Injectable()
export class RateLimitGuard extends ThrottlerGuard {
  protected async handleRequest(requestProps: ThrottlerRequest): Promise<boolean> {
    const { context, limit, ttl, throttler, blockDuration, generateKey, getTracker } =
      requestProps;

    const res: Response = context.switchToHttp().getResponse();
    const req = context.switchToHttp().getRequest();
    const tracker = await getTracker(req);
    const key = generateKey(context, tracker, throttler.name ?? 'default');
    const { totalHits } = await this.storageService.increment(
      key,
      ttl,
      limit,
      blockDuration,
      throttler.name ?? 'default',
    );

    const remaining = Math.max(0, limit - totalHits);
    const reset = Math.floor(Date.now() / 1000) + ttl / 1000;

    res.setHeader('RateLimit-Limit', limit);
    res.setHeader('RateLimit-Remaining', remaining);
    res.setHeader('RateLimit-Reset', reset);

    if (totalHits > limit) {
      res.setHeader('Retry-After', Math.ceil(ttl / 1000));
      throw new ThrottlerException();
    }

    return true;
  }
}

Регистрация глобально через модуль:

@Module({
  imports: [
    ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
  ],
  providers: [{ provide: APP_GUARD, useClass: RateLimitGuard }],
})
export class AppModule {}

Формат ответа 429

Ответ при превышении лимита возвращается в формате RFC 9457 (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',
      });
  }
}

Как выглядит успешный ответ с заголовками

HTTP/1.1 200 OK
RateLimit-Limit: 100
RateLimit-Remaining: 43
RateLimit-Reset: 1719849600
Content-Type: application/json

{ "orderId": "ord-7821", "status": "CONFIRMED" }

Клиент видит, что осталось 43 запроса из 100, и может заранее снизить частоту.

Описание 429 в OpenAPI

Каждый эндпоинт с rate limiting описывает код 429 явно:

@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> { ... }

Загрузка файлов: почему не Base64 в JSON

Бывает соблазн передать файл как строку Base64 внутри JSON. Это удобно синтаксически, но на практике:

  • Base64 раздувает размер данных примерно на 33%.
  • Весь JSON приходится прочитать целиком в память — файл нельзя обрабатывать потоком.
  • Такой запрос не кэшируется и плохо сжимается.

Правильный способ — multipart/form-data. NestJS работает с ним через FileInterceptor из пакета @nestjs/platform-express.

Контроллер загрузки файла

Файл лучше моделировать как вложенный ресурс. Например, вложение к заказу — это POST /orders/{orderId}/attachments, а не отдельный /files/:

@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);
  }
}

NestJS возвращает 201 Created для @Post по умолчанию.

Как выглядит запрос

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--

Ответ после загрузки

{
  "attachmentId": "a3f1e200-12cd-4567-89ab-cdef01234567",
  "fileName": "invoice.pdf",
  "contentType": "application/pdf",
  "size": 204800,
  "uploadedAt": "2026-06-19T09:00:00Z"
}

Заголовок Location с URL созданного ресурса устанавливается через @Res({ passthrough: true }):

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 — встроенный механизм NestJS для потоковой передачи. Без него NestJS попытается сериализовать поток как JSON.

Заголовок Content-Disposition: attachment; filename="..." говорит браузеру сохранить файл с правильным именем, а не открыть во вкладке:

@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);
}

Deprecation: как снять эндпоинт без сюрпризов для клиентов

Удалить эндпоинт молча — плохая идея: клиенты начнут получать ошибки без предупреждения. Правильный подход — объявить о выводе заранее, дать время на миграцию, и только потом закрыть.

Стандартный цикл: пометить → уведомить заголовками → проверить, что трафик упал → закрыть с 410.

Шаг 1. Пометить в OpenAPI

@Get(':orderId/status')
@ApiOperation({
  operationId: 'getOrderStatus',
  summary: 'Получить статус заказа',
  deprecated: true,
  description:
    'DEPRECATED: используйте GET /api/v2/orders/{orderId}. Будет удалён после 2026-12-01.',
})
async getStatus(@Param('orderId', ParseUUIDPipe) orderId: string): Promise<OrderStatusDto> {
  return this.ordersService.getStatus(orderId);
}

Одной пометки в OpenAPI недостаточно — её видят только разработчики, читающие документацию. Заголовки в ответе видит любой клиент автоматически.

Три заголовка:

  • Sunset — дата отключения (RFC 8594).
  • Deprecation — флаг, что эндпоинт помечен устаревшим.
  • Link — ссылка на замену.

Реализуется через декоратор и interceptor, чтобы не засорять контроллер:

// sunset.decorator.ts
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);
// deprecation.interceptor.ts
@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> { ... }

Клиент получает в ответе:

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. Убедиться, что трафик упал

Перед отключением interceptor логирует каждый вызов устаревшего эндпоинта:

tap(() => {
  this.logger.warn(`Deprecated endpoint called: ${context.switchToHttp().getRequest().path}`);
})

Это позволяет отследить по логам, что клиенты перешли на новый эндпоинт. Не стоит отключать раньше, чем трафик станет близким к нулю.

Обычно дают не менее 6 месяцев между объявлением и реальным отключением.

Шаг 4. Закрыть с 410 Gone

После даты отключения эндпоинт не просто удаляется — он явно возвращает 410 Gone с указанием альтернативы:

@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. Ответ возвращается в формате application/problem+json через глобальный Exception Filter.

Разница между 404 и 410: 404 значит «не нашли», 410 значит «был, но намеренно удалён». Поисковики и клиенты реагируют на них по-разному.

Коротко

  • @nestjs/throttler по умолчанию не добавляет RateLimit-* — нужен кастомный RateLimitGuard с переопределённым handleRequest.
  • Заголовки RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset отправляются в каждый ответ, не только при превышении.
  • При превышении: 429 + Retry-After + тело в формате application/problem+json.
  • Файлы принимаются через FileInterceptor + multipart/form-data, а не Base64 в JSON.
  • Файлы отдаются через StreamableFile + Content-Disposition: attachment; filename="...".
  • Deprecation — четырёхшаговый процесс: пометить в OpenAPI → добавить заголовки Sunset/Deprecation/Link → дождаться падения трафика → закрыть с 410 Gone.
  • Без даты в Sunset клиент не знает, когда нужно мигрировать.

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

  • Ошибки и RFC 9457 в NestJS — как устроен application/problem+json и глобальные Exception Filters.
  • Заголовки и трассировка в NestJS — Retry-After, traceparent и другие стандартные заголовки.
  • Версионирование API в NestJS — как перейти с v1 на v2 без удаления старого кода сразу.