HTTP-заголовки — это метаданные запроса и ответа: тип содержимого, адрес созданного ресурса, токен авторизации, идентификатор трассировки. Разберём, как с ними работать в NestJS: что читать, что выставлять и как не наступить на типичные ловушки с именованием.
Стандартные заголовки
В каждом HTTP-запросе и ответе есть заголовки, значение которых определяет стандарт. Ничего изобретать не нужно — нужно правильно их использовать.
Входящие заголовки читают через декоратор @Headers():
@Get(':id')
async findOne(
@Param('id') id: string,
@Headers('if-none-match') ifNoneMatch: string,
@Res({ passthrough: true }) res: Response,
) {
const order = await this.ordersService.findOne(id);
const etag = `"${order.version}"`;
if (ifNoneMatch === etag) {
res.status(304).end();
return;
}
res.setHeader('ETag', etag);
return order;
}
Здесь ETag — версия ресурса: клиент присылает текущее значение в If-None-Match, сервер сравнивает. Если ресурс не изменился, возвращает 304 Not Modified без тела — экономит трафик.
Заголовок Location выставляют при создании ресурса (201 Created) — он сообщает клиенту, по какому адресу найти созданный объект:
@Post()
async create(
@Body() dto: CreateOrderDto,
@Res({ passthrough: true }) res: Response,
) {
const order = await this.ordersService.create(dto);
res.location(`/api/v1/orders/${order.orderId}`);
return order;
}
Частая ошибка — написать просто /${order.orderId} без полного пути. Клиент получит неполный URL и не сможет сделать GET-запрос за созданным ресурсом.
Кастомные заголовки: почему без X-
Если нужно передать данные, которых нет в стандарте (идентификатор клиента, версия приложения, идентификатор запроса), используют собственные заголовки. Раньше их было принято называть с префиксом X- (X-Request-Id, X-Tenant-Id). В 2012 году RFC 6648 этот подход признал устаревшим: X- ничего не означает и только засоряет имена.
Современный подход — доменный префикс: берёте имя вашего проекта или продукта и используете его как пространство имён.
@Post()
async create(
@Headers('shop-request-id') requestId: string,
@Headers('shop-client-version') clientVersion: string,
@Body() dto: CreateOrderDto,
) {
this.logger.log({ requestId, clientVersion }, 'create order');
return this.ordersService.create(dto);
}
HTTP-заголовки регистронезависимы, поэтому shop-request-id и Shop-Request-Id — одно и то же. Принято писать в нижнем регистре через дефис.
Идемпотентный POST через Idempotency-Key
Идемпотентность — это когда повторный запрос даёт тот же результат, что и первый. Для GET это работает само по себе. Для POST — нет: если клиент не получил ответ (таймаут, обрыв сети) и повторил запрос, на сервере может создаться два объекта вместо одного.
Idempotency-Key решает эту проблему: клиент генерирует уникальный ключ (UUID v4) перед операцией и отправляет его в заголовке. Сервер запоминает ключ и результат первого выполнения. При повторном запросе с тем же ключом — возвращает сохранённый результат, не выполняя операцию заново.
export class IdempotencyGuard implements CanActivate {
constructor(private readonly idempotencyService: IdempotencyService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const req = context.switchToHttp().getRequest<Request>();
const key = req.headers['idempotency-key'] as string;
if (!key) {
throw new BadRequestException('Idempotency-Key header is required');
}
const existing = await this.idempotencyService.find(key);
if (existing) {
const res = context.switchToHttp().getResponse<Response>();
res.status(existing.status).json(existing.body);
return false;
}
return true;
}
}
@Post()
@UseGuards(IdempotencyGuard)
async create(
@Headers('idempotency-key') key: string,
@Body() dto: CreateOrderDto,
): Promise<OrderResponse> {
return this.ordersService.create(dto, key);
}
Важные детали контракта:
- Один ключ — одна бизнес-операция. Клиент генерирует его один раз и сохраняет до получения успешного ответа.
- Повторный POST с тем же ключом → сервер возвращает первый результат.
- Если прислать другое тело с тем же ключом →
409 Conflict. Idempotency-Keyимеет смысл только для POST и PATCH. GET-запросы идемпотентны по природе.
traceparent: трассировка запросов
Когда один запрос от клиента проходит через несколько сервисов, сложно понять, где что пошло не так. Для этого существует распределённая трассировка: каждый запрос получает уникальный идентификатор (trace-id), который передаётся от сервиса к сервису в заголовке traceparent. В итоге все шаги одного запроса можно увидеть в одном trace.
Стандарт называется W3C Trace Context. Заголовок traceparent выглядит так:
00-1f2a8b6c7d3e4f5a9b0c1d2e3f4a5b6c-7a8b9c0d1e2f3a4b-01
│ │ │ │
│ trace-id (32 hex-символа) parent-id (16) flags
версия
В NestJS трассировку подключают через OpenTelemetry SDK — один раз при старте приложения, до создания NestJS-приложения:
// main.ts
import { NodeSDK } from '@opentelemetry/sdk-node';
import { HttpInstrumentation } from '@opentelemetry/instrumentation-http';
const sdk = new NodeSDK({
instrumentations: [new HttpInstrumentation()],
});
sdk.start();
После этого traceparent извлекается из входящего запроса автоматически. В коде traceId доступен через OpenTelemetry API:
// telemetry/trace-context.ts
import { context, trace } from '@opentelemetry/api';
export function getTraceId(): string {
const span = trace.getActiveSpan();
return span?.spanContext().traceId ?? '';
}
Чаще всего traceId нужен в теле ошибки — чтобы клиент мог передать его в поддержку:
export function sendProblem(res: Response, status: number, title: string, detail: string): void {
res.status(status).type('application/problem+json').json({
title,
status,
detail,
traceId: getTraceId(),
});
}
Логика работы проста: если клиент прислал traceparent — сервис подхватывает его trace-id и создаёт новый parent-id для своего шага. Если заголовка не было — HttpInstrumentation генерирует новый traceparent при входе.
Коротко
- Читать входящие заголовки — через
@Headers('header-name'). Locationпри201 Createdвыставляют черезres.location(...)с полным путём.ETag+If-None-Matchпозволяют возвращать304без тела при неизменившемся ресурсе.- Собственные заголовки называют с доменным префиксом (
shop-request-id), а не сX-—X--префикс устарел с 2012 года. Idempotency-Keyзащищает POST от двойного создания при повторных запросах — один ключ на бизнес-операцию, сервер запоминает результат.- Трассировка через
traceparentподключается один раз вmain.tsчерезHttpInstrumentation— дальше всё автоматически.
Что почитать дальше
- Ошибки REST в NestJS — как включить
traceIdв тело ошибки RFC 9457. - JSON и формат ответов в NestJS —
Locationпри 201 и форматирование тела. - Rate limiting, файлы, deprecation — заголовки
Retry-AfterиSunset.