Жизненный цикл запроса: измеренный порядок

Nest≈ 15 минут

Здесь будет встроен видео-разбор темы (5–7 минут, YouTube / RuTube / VK Видео) — тем, кто лучше воспринимает на слух, текст ниже не обязателен.

Порядок звеньев вы собрали руками: страж до трубы, перехватчик вокруг обработчика, фильтр рядом с маршрутом. В документации Nest тот же порядок нарисован схемой, и схеме хочется верить. Но схема ничего не доказывает: по ней не видно, чего ждёт next() у прослойки, в какой момент работают трубы и какие звенья пропускаются, когда запрос отказал. Поэтому порядок мы не перескажем, а измерим — журналом, где каждое звено печатает строку, а номер ей ставит очередь вызовов. Четырнадцать строк на удачном запросе, десять на отказавшем — и одна строка стоит не там, где её ждёшь. Заодно станет ясно, зачем это знание: у проверки доступа, разбора входа, замера и ответа на отказ есть ровно одно правильное место, и выбирают его по этому порядку.

Разделы темы:·

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

Измерить порядок проще, чем кажется. Заводится счётчик и одна функция step: она увеличивает счётчик и печатает строку. Дальше эта функция вписывается в каждое звено — по одной строке на звено, а у перехватчика по одной на каждую из двух половин. Номера в журнале расставляет не автор, а очередь вызовов, и в этом вся ценность: соврать такой журнал не может.

Начнём со своего каркаса — в нём вы знаете каждую строку. Звенья расставлены не на всех уровнях сразу: прослойка в createApp, стражи и трубы на классе и на методе, перехватчики на общем уровне, на классе и на методе, фильтр на классе. Второй запрос отказывает в трубе. Прогноз: сколько строк выйдет в каждом журнале и на каком месте окажется вторая половина прослойки?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

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

Второй журнал короче на четыре строки, и пропали в нём вовсе не случайные. Труба класса отказала — значит, не было ни трубы метода, ни обработчика: до них просто не дошли. Не было и ни одной «строки под next()» у перехватчиков: исключение прошло сквозь их await, и к строкам под ним никто не вернулся. А вот фильтр встал восьмым, сразу после отказавшей трубы, и прослойка всё равно допечатала свою вторую половину: она видит уже собранный ответ, каким бы он ни был.

Журнал — не отладочный приём, а способ задать фреймворку вопрос так, чтобы он не мог ответить уклончиво.

Теперь то же приложение на Nest 12.0.3. Переписывать почти нечего: имена звеньев и места, где они объявляются, уже разложены по колонкам. Прослойка объявляется методом configure модуля, страж — классом с canActivate, перехватчик — классом с intercept, труба — классом с transform, фильтр — классом с @Catch. Запросы идут через app.inject, как и в прошлых прогонах главы: ни порта, ни сети, а конвейер настоящий.

Одно отличие в расстановке всё же есть, и оно не в устройстве, а в наполнении журнала: общий уровень у стража и трубы в вашем каркасе есть, просто на него никто не записан. В прогоне Nest на общий уровень записаны и страж, и труба, поэтому стражей там три, а не два, — отсюда и четырнадцать строк вместо тринадцати. Трубы у него тоже две, но других уровней: общая на всё приложение и труба у параметра вместо класса и метода.

Вторая половина прослойки в вашем журнале стояла последней. Прогноз: где она окажется здесь — и сколько строк останется, когда откажет труба?

прогон node 26.5.0 · 23 сентября 2026
Журнал всех звеньев Nest 12.0.3: удачный запрос и отказ трубы
import {
  ArgumentsHost,
  BadRequestException,
  CallHandler,
  CanActivate,
  Catch,
  ExceptionFilter,
  ExecutionContext,
  Injectable,
  NestInterceptor,
  PipeTransform,
} from '@nestjs/common';
import { Observable, tap } from 'rxjs';

// Журнал: номер строке даёт не автор, а очередь вызовов
let n = 0;

export function announce(title: string) {
  n = 0;
  console.log(title);
}

export function step(name: string) {
  n += 1;
  console.log(`${n}. ${name}`);
}

// Уровней у стража три, и различает их только имя в журнале
function guard(where: string) {
  @Injectable()
  class Guard implements CanActivate {
    async canActivate(_context: ExecutionContext) {
      step(`страж ${where}`);
      return true;
    }
  }
  return Guard;
}

export const GlobalGuard = guard('общего уровня');
export const ClassGuard = guard('класса');
export const RouteGuard = guard('метода');

// У перехватчика две половины: строка до next.handle() и оператор над его потоком
function around(where: string) {
  @Injectable()
  class Around implements NestInterceptor {
    intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
      step(`перехватчик ${where}: до next.handle()`);
      return next.handle().pipe(tap(() => step(`перехватчик ${where}: оператор после`)));
    }
  }
  return Around;
}

export const GlobalAround = around('общего уровня');
export const ClassAround = around('класса');
export const RouteAround = around('метода');

@Injectable()
export class GlobalPipe implements PipeTransform {
  transform(value: unknown) {
    step('труба общего уровня');
    if (value === 'нет') throw new BadRequestException('такого адреса не бывает');
    return value;
  }
}

@Injectable()
export class ParamPipe implements PipeTransform {
  transform(value: unknown) {
    step('труба параметра');
    return value;
  }
}

@Catch(BadRequestException)
export class BadRequestFilter implements ExceptionFilter {
  catch(_error: BadRequestException, host: ArgumentsHost) {
    step('фильтр класса');
    host.switchToHttp().getResponse().status(400).send({ code: 'BAD_REQUEST' });
  }
}

// Прослойка платформы: те же три аргумента, что у звена вашего конвейера
export function journalMiddleware(_req: unknown, _res: unknown, next: () => void) {
  step('прослойка: до next()');
  next();
  step('прослойка: строка под next()');
}
nest start

Середина журнала совпала со своим каркасом до строки. Стражи (2, 4, 5) — от общего к частному и все до единого. Перехватчики (6, 7, 8) — в том же порядке, и обратным порядком их операторы на выходе (12, 13, 14). Трубы (9, 10) — после всех перехватчиков и вплотную к обработчику (11). Это то самое место, которое разбиралось по исходнику: трубы и вызов метода Nest собирает в одну функцию и отдаёт её перехватчикам. Теперь это не вывод из чтения кода, а строка 9 под строкой 8.

Оба практических следствия этого места читаются теперь прямо по журналу. Замер перехватчика общего уровня открывается строкой 6 и закрывается строкой 14 — трубы (9, 10) оказались внутри, то есть в замер вошло и время разбора входа. А во втором журнале отказ трубы не дал ни одной строки «после», зато дошёл до фильтра: к перехватчику он приходит тем же потоком, только каналом ошибки, — и потому оператор, которым перехватчик пишет своё «после», выбирают не наугад.

На отказе десять строк. Трубы параметра нет, обработчика нет, операторов «после» нет ни одного — поток закончился ошибкой, и tap по нему не прошёл. Отвечает фильтр класса: он объявлен через @Catch(BadRequestException), а труба бросила именно её. Ответ у фильтра свой, короткий, вместо длинного тела Nest — и в этом весь смысл фильтра рядом с маршрутом.

Расходится журнал со своим каркасом ровно в одном месте — третья строка. Вторая половина прослойки в Nest выполняется не последней, а почти сразу, обогнав и стражей, и обработчик. Это не мелочь оформления, и следующий раздел именно о ней.

Четырнадцать строк на удачном запросе — и ни одна из них не взята из документации.

Вернёмся к третьей строке. В вашем каркасе звено конвейера ждёт: await next() — промис всего хвоста цепочки, и строка под ним выполняется последней. У прослойки Nest подпись другая: (req, res, next), где next — обычная функция без возвращаемого значения. Платформа зовёт следующий слой и не ждёт его, а обработчик Nest асинхронный: на первом же await внутри управление возвращается в прослойку. Отсюда и третья строка — прослойка досказала свою вторую половину, пока стражи ещё шли.

Значит, прослойка — плохое место для всего, что должно случиться после ответа: замера длительности, записи «сколько вернули», очистки. Написать такое там можно, и оно даже выполнится — просто не тогда, когда нужно. Правильное место — перехватчик: это единственное звено, внутри которого обработчик действительно выполняется.

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

Но и в перехватчике есть развилка. Вторая половина у него пишется не строкой под await, а оператором над потоком, и операторы разные. tap подглядывает за значениями потока. finalize срабатывает на любом его завершении. Пока запросы удачные, разницы не видно.

Ниже два перехватчика общего уровня — один с tap, другой с finalize, — и прослойка, которая отмечает, закончил ли обработчик к моменту её второй половины. Два запроса: удачный и отказавший. Прогноз: сколько замеров запишет каждый из перехватчиков?

прогон node 26.5.0 · 23 сентября 2026
Замер прослойкой, оператором tap и оператором finalize на удачном запросе и на отказе
import { CallHandler, ExecutionContext, Injectable, NestInterceptor } from '@nestjs/common';
import { Observable, finalize, tap } from 'rxjs';

// Записи замеров: сами миллисекунды на разных прогонах разные, считаем строки
export const measured: { where: string; ms: number }[] = [];

// Обработчик отмечается, когда дошёл до конца
export const handler = { done: false };

@Injectable()
export class TapTimer implements NestInterceptor {
  intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const started = performance.now();
    return next.handle().pipe(tap(() => measured.push({ where: 'tap', ms: performance.now() - started })));
  }
}

@Injectable()
export class FinalizeTimer implements NestInterceptor {
  intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
    const started = performance.now();
    return next.handle().pipe(finalize(() => measured.push({ where: 'finalize', ms: performance.now() - started })));
  }
}

// Третий наблюдатель — прослойка: своё «после» она пишет строкой под next(), а не оператором
export function timerMiddleware(_req: unknown, _res: unknown, next: () => void) {
  next();
  console.log(`прослойка под next(): обработчик уже закончил — ${handler.done ? 'да' : 'нет'}`);
}
nest start

Прослойка оба раза застала обработчика на середине: её вторая половина выполнилась раньше, чем он дошёл до конца. tap записал один замер из двух — тот, что с ответом; отказ ушёл по каналу ошибки, мимо него. finalize записал оба. Это и есть правило: замер, журнал длительности, освобождение ресурса — всё, что обязано случиться независимо от исхода, пишется через finalize. tap остаётся там, где интересен именно успешный ответ.

В вашем перехватчике обе половины — обычные строки вокруг await next(), и «выполнить всегда» там делается тем же try … finally, каким это делается в любом коде. Разница появляется от потока: у Nest перехватчик возвращает Observable, и у потока исходов больше, чем у промиса, — значение, ошибка, завершение и отписка. Поэтому и операторов больше, и выбирать между ними приходится осознанно.

Плата за поток не только в этом, но и выгода настоящая: timeout одним оператором ставит ограничение по времени на обработчик, а catchError подменяет отказ ответом — в вашем каркасе и то и другое пришлось бы писать руками вокруг await.

Замер ставят перехватчиком и через finalize. Всё остальное — способ потерять ровно те измерения, ради которых замер заводили.

Прослойка выглядит удобным местом для проверки доступа: она общая, стоит раньше всех, видит заголовки и пишется одной функцией. Смущать должно то, что в её распоряжении из всего запроса есть только строка адреса: маршрут в этот момент ещё не выбран, и какой контроллер ответит — неизвестно. Значит, правило приходится записывать текстом пути, и совпадать оно обязано с тем, как этот путь прочтёт маршрутизатор.

А читает он его не буквально. Путь режется на сегменты, и каждый сегмент декодируется отдельно: проценты в адресе — это кодировка, а не текст. Прослойка же сравнивает строку как строку. Ниже одно приложение с проверкой прослойкой и второе — с той же проверкой стражем на контроллере. Два адреса, и оба ведут в один и тот же маршрут. Прогноз: какие четыре ответа выйдут?

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Первый адрес закрыт в обоих приложениях. Второй — /%61dmin/users, где %61 это буква «a», — маршрутизатор разобрал в те же два сегмента и отдал тому же обработчику, а проверка прослойкой на нём не сработала вовсе: строка начинается не с /admin. Секрет уехал клиенту. Стражу это написание безразлично: он выполняется после выбора маршрута и стоит на самом контроллере, а не на строке адреса.

На настоящем Nest к этому добавляется ещё одна подробность, и узнать о ней лучше не из боевого журнала. Прогон ниже — то же самое приложение дважды, и прослойка в нём печатает, каким она видит адрес запроса. Прогноз: что окажется в этой строке?

прогон node 26.5.0 · 23 сентября 2026
Проверка доступа прослойкой и стражем на Nest 12.0.3, два написания одного адреса
import { CanActivate, Controller, ExecutionContext, Get, Injectable, UseGuards } from '@nestjs/common';

// Проверка доступа прослойкой: маршрут ещё не выбран, есть только строка адреса
export function adminOnlyByPath(req: any, res: any, next: () => void) {
  console.log(`прослойка: req.url=${JSON.stringify(req.url)} req.originalUrl=${JSON.stringify(req.originalUrl)}`);
  if (String(req.originalUrl).startsWith('/admin') && req.headers.role !== 'admin') {
    res.statusCode = 403;
    res.end(JSON.stringify({ code: 'FORBIDDEN' }));
    return;
  }
  next();
}

// Та же проверка стражем: маршрут уже выбран, страж стоит на самом контроллере
@Injectable()
export class AdminOnly implements CanActivate {
  canActivate(context: ExecutionContext) {
    return context.switchToHttp().getRequest().headers.role === 'admin';
  }
}

@Controller('admin')
export class ByPathController {
  @Get('users')
  users() {
    return { secret: 'список всех учёток' };
  }
}

@Controller('admin')
@UseGuards(AdminOnly)
export class ByGuardController {
  @Get('users')
  users() {
    return { secret: 'список всех учёток' };
  }
}
nest start

В req.url прослойки лежит "/". Прослойка в Nest монтируется на образец пути, и платформа отдаёт ей остаток адреса после совпавшей части — настоящий адрес остаётся в req.originalUrl. Образец из forRoutes('*') совпадает со всем адресом, остаток пуст, и в req.url остаётся один "/". Проверка, написанная по req.url, не закрыла бы ничего и никогда — и не подала бы вида: адреса в её строке просто нет. А проверка по req.originalUrl ведёт себя ровно как в песочнице: первый адрес закрывает, второй пропускает. Страж закрывает оба.

С другой стороны эту же границу вы уже переходили: разбор входа, положенный в прослойку, выдаёт гостю форму ваших данных ещё до того, как страж скажет «нельзя». Изъян там и здесь один и тот же — прослойка работает снаружи маршрута, — только в одном случае наружу утекает знание о полях, а в другом сам ответ. Стоит запомнить его одной фразой, а не двумя случаями.

Правило из этого простое и держится на порядке: всё, что зависит от того, куда идёт запрос, пишется после маршрутизатора. Прослойке остаётся то, что одинаково для любого адреса, включая несуществующий, — заголовки безопасности, сжатие, чужая готовая прослойка из npm. Проверка доступа к этому списку не относится.

Проверка, написанная строкой адреса, проверяет строку адреса. Маршрут она не проверяет никогда.

Измеренный порядок нужен ради одного вопроса, который возникает каждый рабочий день: «куда это положить». Ответ на него выводится из журнала почти механически — по тому, что звено видит к моменту своего вызова и что оно успевает увидеть после.

ЗадачаЗвеноПочему по журналу выходит так
заголовки безопасности, сжатие, чужая прослойка из npmпрослойкастоит снаружи маршрутизатора и работает на любой адрес, включая тот, которого нет; про сам маршрут не знает ничего
проверка доступастражвыполняется после выбора маршрута и до труб: знает, куда идут, и отказывает раньше, чем кто-то заплатит за разбор входа
разбор и проверка входатрубапоследняя перед обработчиком: что вышло из неё, то обработчик и получит
замер, журнал длительности, конверт ответа, обрезка полейперехватчикединственное звено, внутри которого выполняется обработчик, — видит и вход, и то, что вернули
своя форма ответа на отказфильтротвечает ближний к обработчику, поэтому частное правило кладётся рядом с частным маршрутом, а общее остаётся общим

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

У Nest есть второй набор хуков, и путать его с конвейером запроса легко по названию. onModuleInit, onApplicationBootstrap, onModuleDestroy и onApplicationShutdown относятся к жизни самого приложения, а не запроса: они срабатывают один раз при старте и один раз при остановке. В журнале запроса их нет и быть не может.

Порядок у них свой, и в 12-й версии он как раз изменился: хуки зовутся по уровню иерархии компонентов (релиз v12.0.0). Конвейера запроса эта правка не коснулась — измеренный журнал выше совпал с вашим каркасом звено в звено. Подробности у обоих концов: падение на старте и остановка, которая дослуживает начатое. Одну ловушку стоит запомнить уже сейчас: по сигналу от docker или по Ctrl+C хук завершения не позовут, пока в main.ts нет вызова app.enableShutdownHooks(): подписывается на сигналы он, а не объявление хука (проверено по исходнику @nestjs/core 12.0.3).

Порядок измерен, места разобраны — а весь этот конвейер, от прослойки до фильтра, стоит поверх платформы, которую Nest подставляет под себя. Чем Fastify отличается от Express, что при такой замене меняется в прослойках и почему адаптер вообще существует, разбирает следующая статья главы.

Вопрос «куда это положить» решается не вкусом, а журналом: смотрят, что звено видит и когда.

  • Порядок звеньев не пересказывают, а измеряют: счётчик и строка в каждом звене отвечают на вопросы, на которые схема в документации не отвечает.
  • В Nest 12 порядок такой: прослойка, стражи (общий, класса, метода), перехватчики «до» в том же порядке, трубы, обработчик, операторы перехватчиков в обратном порядке, фильтр на отказе. Ваш каркас совпадает с ним звено в звено — кроме одной строки.
  • Трубы лежат внутри матрёшки перехватчиков, вплотную к обработчику: замер перехватчиком включает разбор входа, а отказ трубы приходит к нему обычным исключением.
  • Вторая половина прослойки в Nest выполняется почти сразу, а не после ответа: её next() ничего не ждёт. Всё, что должно случиться после ответа, пишется перехватчиком.
  • Замер и всё, что обязано случиться независимо от исхода, — через finalize: tap теряет ровно те запросы, ради которых замер и заводили.
  • Звено выбирают по тому, что оно видит: прослойка не знает маршрута и потому не годится для проверки доступа, страж стоит после маршрутизатора, труба готовит вход, перехватчик видит ответ, фильтр решает, как отказ выглядит снаружи.

1/4В журнале Nest строка «прослойка: строка под next()» встала третьей, а не последней. Почему?

2/4Где в Nest выполняются трубы относительно перехватчиков?

3/4Перехватчик записывает длительность оператором tap. Что станет с замером, если обработчик бросит исключение?

4/4Почему проверку доступа не стоит писать прослойкой?

Конверт съел обрезку

У приложения два сквозных правила ответа: наружу выходят только перечисленные поля (only) и всякий ответ едет в конверте (envelope). Оба написаны перехватчиками, оба выглядят верно — а клиент получает пустое тело. Поменяйте расстановку так, чтобы ответом стал {"data":{"id":"7","title":"купить хлеб"}}. Сами перехватчики, обработчик и каркас не трогайте.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Перехватчики — матрёшка, и уровни складываются от общего к частному: перехватчик класса стоит снаружи перехватчика метода. Сейчас снаружи only, и до него доезжает уже готовый конверт: в объекте { data: … } полей id и title нет, и белый список честно оставляет от ответа пустоту. Лечится перестановкой уровней:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Теперь обрезка идёт первой — вплотную к обработчику, на настоящей строке хранилища, — а конверт надевается на уже обрезанное. Ответ становится 200 {"data":{"id":"7","title":"купить хлеб"}}, и authorEmail наружу не выходит.

Правило общее: чем ближе перехватчик к обработчику, тем «сырее» данные, которые он видит. Всё, что работает с содержимым ответа, ставят внутрь; всё, что работает с его внешней формой, — наружу. Перепутать их местами компилятор не помешает: типы у обоих одинаковые.

Кеш, который всё равно зовёт обработчик

Перехватчик cached написан ради того, чтобы повторный запрос на тот же адрес отвечал из памяти. Ответы он и правда отдаёт одинаковые, но счётчик в конце показывает, что обработчик выполнился трижды — то есть ровно столько раз, сколько было запросов, и весь смысл кеша потерян. Почините перехватчик так, чтобы обработчик выполнился один раз, а ответы остались прежними.

ts
Tab добавляет отступ. Чтобы выйти из редактора, нажмите Escape — фокус перейдёт на кнопку запуска.
страница примера

Обработчик выполняется внутри next(), а значит, кеш обязан отвечать до этого вызова, а не после. В исходном коде await next() стоит первой строкой, и сколько бы ни лежало в store, обработчик уже отработал:

jsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Счётчик становится равен единице, ответы не меняются. Заодно перестают выполняться и трубы: они лежат внутри матрёшки, глубже любого перехватчика, — а значит, кеш экономит не только работу обработчика, но и разбор входа.

Это и есть главное умение перехватчика, которого нет ни у стража, ни у трубы: он может не позвать next() вовсе и ответить сам. В Nest тем же приёмом пишется CacheInterceptor, только вместо значения он возвращает свой поток.

Сколько строк останется в журнале

Перед вами контроллер из прогона этой статьи с одной правкой: страж класса теперь отвечает «нет». Прослойка, страж общего уровня, перехватчик общего уровня, труба общего уровня и фильтр класса остались на своих местах. Ответьте по листингу, не запуская: сколько строк напечатает журнал, какие это строки и каким будет ответ.

tsлистинг · не запускается
Листинг только для чтения: этот код не запускается в браузере.

Строк четыре: «прослойка: до next()», «страж общего уровня», «прослойка: строка под next()» и «страж класса». Ответ — 403 с телом Nest {"message":"Forbidden resource","error":"Forbidden","statusCode":403} (проверено на Nest 12.0.3).

Разбирается это по журналу построчно. Стражи идут от общего к частному, и первый же ответивший «нет» обрывает цепочку — до стража метода дело не дошло. Всё, что стоит после стражей, не выполнялось вовсе: ни одного перехватчика, ни одной трубы, ни обработчика. Вторая половина прослойки успела напечататься третьей по той же причине, что и в прогоне: её next() ничего не ждёт.

Отдельный вопрос — фильтр. Он на месте и объявлен на классе, но @Catch(BadRequestException) ловит только её, а страж бросает ForbiddenException. Фильтр не сработал, и отвечал встроенный обработчик исключений Nest — поэтому тело длинное, а не короткое {"code":"BAD_REQUEST"}.