Три темы, которые регулярно возникают в реальных 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);
}
Шаг 2. Добавить заголовки Sunset / Deprecation / Link
Одной пометки в 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 без удаления старого кода сразу.