Skip to content

The Request Path

This page follows a single request through @blixis-io/http in the order things happen. The other concept pages each cover one stage in depth; this one shows how they fit together, so you can answer “where does this get checked?” and “why did I get that status?”.

socket > Request > [middleware] > timeout > route match > RequestContext scope > Response > [middleware] > socket
inside the scope: guards > interceptors > params + body > handler
exceptions thrown anywhere inside the scope become responses

listen() runs on Node’s HTTP server and converts each incoming message to a Web-standard Request. createFetchHandler skips this stage: the platform already hands you a Request.

  • Node’s parser rejects a malformed request before any of your code runs: a bad request line, oversized headers (431), or a bad or conflicting Content-Length (400). See Running in Production.
  • A body is only attached when the request says it carries one (Content-Length above 0, or Transfer-Encoding). It stays an unread stream; nothing is buffered yet.
  • request.signal aborts if the client disconnects.
  • request.url’s origin is the address the server listens on, unless you opt in to trusting the Host or X-Forwarded-* headers; see The origin of request.url.

With the middleware option, every request passes through the chain first, in order, and every response passes back through it in reverse, whatever produced it: a controller, a guard’s 403, a 404, a 405, a malformed-path 400, a mounted route or a 504. A middleware can answer without calling next(). The chain runs inside a RequestContext scope that the guards and the controller then share, so a value a middleware sets is the one they read. See Middleware.

With requestTimeout, request.signal also aborts after that many milliseconds, so a handler that passes the signal on to fetch or a database call stops when the request does.

The path is split into segments and each one is percent-decoded once (hello%20world becomes hello world; see how a request is matched). The decoded path and the method then pick one route.

Result Status
A broken % escape in the path (/posts/100%) 400
No route for this path 404
Path matches, method doesn’t 405 with an Allow header

All three are answered right here. No guard, interceptor or handler runs, and no RequestContext scope is opened (unless you configured middleware, which opens one around the whole request and sees these responses too).

Everything from here to the response runs inside one RequestContext scope. Values set in a guard are visible to interceptors, the handler and any service they call, and to no other request.

Guards run one after another and stop at the first denial, in this order: global guards, then the controller’s @UseGuards, then the route’s. A guard that returns false gives 403; one that throws gives whatever it threw (401 from UnauthorizedException, for example).

Guards run before the body is read and before parameters are resolved. A request that fails authorization costs a header check, not a body upload and a schema parse.

Interceptors wrap everything after this point: the controller’s outermost, the route’s innermost. Each one calls next() to continue and can inspect, replace or time the Response on the way back. An interceptor can also skip next() and answer on its own, in which case the handler never runs.

The innermost step builds the handler’s arguments from @Param, @Query, @Headers, @Req and @Body. They resolve together, and a schema on any of them is checked here.

The body is read lazily, only if some parameter asks for it, and at most once:

Problem Status
Body isn’t JSON (application/json or a +json type, in UTF-8) 415
Larger than bodyLimit (declared or counted while streaming) 413
Cut off before it was fully received 400
Not valid JSON 400
Fails its schema 400 with issues

A route with no @Body never touches the body stream at all.

The handler runs as an ordinary method. What it returns becomes the response:

  • A plain value is serialized as JSON with status 200, or the code from @HttpCode.
  • undefined is an empty 204.
  • A Response is passed through untouched. It is also exempt from response validation, which makes it the way to stream a body.

A plain value is first checked against the route’s @Returns schema. A mismatch is a 500: the client never sees the internal shape, and the problem is logged.

Anything thrown in stages 5 to 8 is caught in one place:

  • An HttpException becomes its status as application/problem+json.
  • Anything else is logged and answered with a generic 500 An unexpected error occurred. The message is never sent to the client.

10. Disconnects and timeouts, while the work runs

Section titled “10. Disconnects and timeouts, while the work runs”

Everything after routing (guards, argument parsing, interceptors and the handler) runs in a race against the request’s signal:

  • If the client disconnects, the response is a 499. Nobody receives it, since the connection is gone; it only marks the outcome as a client abort rather than a success or a server error.
  • If requestTimeout fires first, the response is a 504.

The race does not cancel the work. A guard or handler that ignores request.signal keeps running to its end, in its own RequestContext scope, and its late result is discarded. What does stop is the next step: once the deadline has passed or the client has left, the next guard and the controller method are not called, so a guard that settles late cannot trigger a handler whose client already got the 504. This applies when requestTimeout is set; without it a request always runs to completion. A route registered with mount() gets the same deadline.

listen() writes the status and headers, then pipes the body with backpressure, so a client that reads slowly doesn’t make the server buffer a large stream. If the client disconnects mid-response, the body stream is cancelled so its producer stops. A response for a client that is already gone is dropped.