Three topics that come up regularly in real-world APIs: how to limit the number of requests, how to accept and serve files, and how to carefully shut down an old endpoint without breaking clients.
Rate limiting: why a 429 without headers is only half the story
When a client hits the API too often, the server returns 429 Too Many Requests. That is correct. But the client needs to know not only "you exceeded the limit" but also when it can try again and how many requests are still left in the current window. Three headers exist for this:
RateLimit-Limit— the maximum number of requests per window (for example, 100 per minute).RateLimit-Remaining— how many requests are still available before the reset.RateLimit-Reset— when the counter resets (Unix timestamp).
An important point: these headers are sent in every successful response, not only when the limit is exceeded. That way the client sees the limit state ahead of time and can slow itself down.
When the limit is exceeded, one more header is added — Retry-After (seconds until reset).
The problem with @nestjs/throttler by default
The @nestjs/throttler package returns 429 out of the box, but it does not add the RateLimit-* headers. You need to extend the standard 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;
}
}
Register it globally through the module:
@Module({
imports: [
ThrottlerModule.forRoot([{ ttl: 60_000, limit: 100 }]),
],
providers: [{ provide: APP_GUARD, useClass: RateLimitGuard }],
})
export class AppModule {}
The 429 response format
When the limit is exceeded, the response is returned in the RFC 9457 format (application/problem+json). An Exception Filter intercepts the 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: `Request limit exceeded. Try again in ${retryAfter} seconds.`,
code: 'RATE_LIMIT_EXCEEDED',
});
}
}
What a successful response with headers looks like
HTTP/1.1 200 OK
RateLimit-Limit: 100
RateLimit-Remaining: 43
RateLimit-Reset: 1719849600
Content-Type: application/json
{ "orderId": "ord-7821", "status": "CONFIRMED" }
The client sees that 43 of 100 requests remain and can lower its rate ahead of time.
Describing 429 in OpenAPI
Every endpoint with rate limiting describes the 429 code explicitly:
@ApiResponse({
status: 429,
description: 'Too Many Requests',
headers: {
'Retry-After': {
schema: { type: 'integer' },
description: 'Seconds until the limit resets',
},
'RateLimit-Limit': { schema: { type: 'integer' } },
'RateLimit-Remaining': { schema: { type: 'integer' } },
'RateLimit-Reset': {
schema: { type: 'integer' },
description: 'Unix timestamp of the window reset',
},
},
schema: { $ref: getSchemaPath(ProblemDetailsDto) },
})
@UseGuards(RateLimitGuard)
@Get()
async getOrders(): Promise<OrdersPageDto> { ... }
File uploads: why not Base64 in JSON
There is a temptation to pass a file as a Base64 string inside JSON. It is syntactically convenient, but in practice:
- Base64 inflates the data size by roughly 33%.
- The entire JSON has to be read into memory in full — the file cannot be processed as a stream.
- Such a request is not cacheable and compresses poorly.
The correct approach is multipart/form-data. NestJS works with it through FileInterceptor from the @nestjs/platform-express package.
The file upload controller
A file is best modeled as a nested resource. For example, an attachment to an order is POST /orders/{orderId}/attachments, not a separate /files/:
@ApiTags('Orders')
@Controller('orders')
export class OrderAttachmentsController {
constructor(private readonly attachmentsService: OrderAttachmentsService) {}
@Post(':orderId/attachments')
@ApiOperation({ operationId: 'uploadOrderAttachment', summary: 'Upload an attachment to an order' })
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
required: ['file'],
properties: {
file: {
type: 'string',
format: 'binary',
description: 'Maximum 10 MB. Allowed types: 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 returns 201 Created for @Post by default.
What the request looks like
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"
Invoice for March 2026
------Boundary--
The response after upload
{
"attachmentId": "a3f1e200-12cd-4567-89ab-cdef01234567",
"fileName": "invoice.pdf",
"contentType": "application/pdf",
"size": 204800,
"uploadedAt": "2026-06-19T09:00:00Z"
}
The Location header with the URL of the created resource is set through @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;
}
Downloading a file
To serve a file, use StreamableFile — the built-in NestJS mechanism for streaming. Without it, NestJS will try to serialize the stream as JSON.
The Content-Disposition: attachment; filename="..." header tells the browser to save the file with the correct name rather than open it in a tab:
@Get(':orderId/attachments/:attachmentId')
@ApiOperation({ operationId: 'downloadOrderAttachment', summary: 'Download an order attachment' })
@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: how to retire an endpoint without surprising clients
Removing an endpoint silently is a bad idea: clients will start getting errors with no warning. The right approach is to announce the retirement ahead of time, give clients time to migrate, and only then shut it down.
The standard cycle: mark → notify with headers → confirm traffic has dropped → close with 410.
Step 1. Mark it in OpenAPI
@Get(':orderId/status')
@ApiOperation({
operationId: 'getOrderStatus',
summary: 'Get order status',
deprecated: true,
description:
'DEPRECATED: use GET /api/v2/orders/{orderId}. Will be removed after 2026-12-01.',
})
async getStatus(@Param('orderId', ParseUUIDPipe) orderId: string): Promise<OrderStatusDto> {
return this.ordersService.getStatus(orderId);
}
Step 2. Add the Sunset / Deprecation / Link headers
A mark in OpenAPI alone is not enough — only developers reading the documentation see it. Headers in the response are seen by any client automatically.
Three headers:
Sunset— the shutdown date (RFC 8594).Deprecation— a flag that the endpoint is marked as deprecated.Link— a link to the replacement.
This is implemented via a decorator and an interceptor so as not to clutter the controller:
// 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"`);
}),
);
}
}
Applying it to the endpoint:
@Get(':orderId/status')
@ApiOperation({ deprecated: true, ... })
@Sunset({ date: '2026-12-01', successor: '/api/v2/orders/{orderId}' })
@UseInterceptors(DeprecationInterceptor)
async getStatus(...): Promise<OrderStatusDto> { ... }
The client receives in the response:
HTTP/1.1 200 OK
Sunset: Tue, 01 Dec 2026 00:00:00 GMT
Deprecation: true
Link: </api/v2/orders/{orderId}>; rel="successor-version"
Step 3. Confirm traffic has dropped
Before shutting down, the interceptor logs every call to the deprecated endpoint:
tap(() => {
this.logger.warn(`Deprecated endpoint called: ${context.switchToHttp().getRequest().path}`);
})
This lets you track through the logs that clients have moved to the new endpoint. Do not shut it down before traffic is close to zero.
You usually allow at least 6 months between the announcement and the actual shutdown.
Step 4. Close it with 410 Gone
After the shutdown date, the endpoint is not simply removed — it explicitly returns 410 Gone while pointing to the alternative:
@Get(':orderId/status')
@ApiOperation({ summary: 'Get order status (removed)', 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: 'The endpoint has been removed. Use GET /api/v2/orders/{orderId}.',
code: 'ENDPOINT_REMOVED',
});
}
GoneException is a built-in NestJS class. The response is returned in the application/problem+json format through the global Exception Filter.
The difference between 404 and 410: 404 means "not found," 410 means "it existed but was deliberately removed." Search engines and clients react to them differently.
In short
@nestjs/throttlerdoes not addRateLimit-*by default — you need a customRateLimitGuardwith an overriddenhandleRequest.- The
RateLimit-Limit,RateLimit-Remaining, andRateLimit-Resetheaders are sent in every response, not only when the limit is exceeded. - On exceedance:
429+Retry-After+ a body in theapplication/problem+jsonformat. - Files are accepted through
FileInterceptor+multipart/form-data, not Base64 in JSON. - Files are served through
StreamableFile+Content-Disposition: attachment; filename="...". - Deprecation is a four-step process: mark it in OpenAPI → add the
Sunset/Deprecation/Linkheaders → wait for traffic to drop → close with410 Gone. - Without a date in
Sunset, the client does not know when it needs to migrate.
Further reading
- Errors and RFC 9457 in NestJS — how
application/problem+jsonand global Exception Filters work. - Headers and tracing in NestJS —
Retry-After,traceparent, and other standard headers. - API versioning in NestJS — how to move from v1 to v2 without removing the old code right away.