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

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.