Knead

Search every guide and package page, down to section headings.

↑↓ move Enter open Esc close

HTTP

Errors

Throw HttpException and its subclasses, get RFC 9457 problem responses, and understand how Knead masks and logs server errors outside development.

On this page 10 sections

Knead answers every error with an RFC 9457 problem response (application/problem+json). Throw an HttpException to choose the status and the details a client sees; anything else you throw is treated as a bug and becomes a masked 500.

#Throwing HTTP exceptions

Throw an exception from a handler, guard, middleware or service. It travels up to the request pipeline, which turns it into the response.

checkout.controller.ts
@Controller('/checkout')
export class CheckoutController {
  readonly #inventory = inject(InventoryClient);
  readonly #errors = inject(ErrorLog);

  @Post('/:sku')
  async buy({ params }: Ctx<{ params: { sku: string } }>) {
    if (params.sku === 'sold-out') {
      throw new ConflictException(`SKU ${params.sku} is sold out`, {
        type: 'https://example.com/problems/sold-out',
        extensions: { sku: params.sku },
      });
    }
    if (params.sku === 'unpaid') throw new PaymentRequiredException('Settle your balance first');
    try {
      await this.#inventory.reserve(params.sku);
    } catch (error) {
      // The 503 is logged with its own message only; record the underlying error as well.
      this.#errors.log(`Reserving ${params.sku} failed`, error, 'warn');
      throw new ServiceUnavailableException('Inventory is unavailable', {
        headers: { 'retry-after': '30' },
        cause: error,
      });
    }
    return { reserved: params.sku };
  }

  @Get('/crash')
  crash(): never {
    throw new Error('Lost connection to redis://default:s3cret@cache:6379');
  }
}

new HttpException(status, detail?, options?) takes the status (an integer from 400 to 599; any other value throws a RangeError), an optional detail for the client, and these options:

OptionTypeDefaultDescription
typestring'about:blank'A URI identifying the problem type.
titlestringthe status's title from STATUS_TITLES, else its reason phrase from node:http's STATUS_CODES, or 'Error'A short summary of the problem type.
extensionsRecord<string, unknown>{}Extra members added to the problem body.
headersBun.HeadersInitnoneHeaders added to the problem response, such as Retry-After or WWW-Authenticate.
causeunknownnoneThe underlying error, kept as the standard Error.cause. Never sent to the client.

The exception's message is its detail, or its title when there is no detail, and its name is the class name (ConflictException, or your own subclass's name).

#Built-in subclasses

Each subclass takes (detail?, options?):

ClassStatusTitle
BadRequestException400Bad Request
UnauthorizedException401Unauthorized
ForbiddenException403Forbidden
NotFoundException404Not Found
MethodNotAllowedException405Method Not Allowed
ConflictException409Conflict
UnsupportedMediaTypeException415Unsupported Media Type
UnprocessableEntityException422Unprocessable Content
TooManyRequestsException429Too Many Requests
InternalServerErrorException500Internal Server Error
ServiceUnavailableException503Service Unavailable

ValidationException is a BadRequestException too (see validation). STATUS_TITLES also has titles for 413 (Content Too Large) and 421 (Misdirected Request), which Knead uses for its own responses. Any other status takes its standard reason phrase from node:http's STATUS_CODES (502 is Bad Gateway, 504 is Gateway Timeout); a status with none, such as 499, is 'Error' unless you pass a title.

#Your own exceptions

Extend HttpException for statuses or problem types that recur in your API:

payment-required.exception.ts
export class PaymentRequiredException extends HttpException {
  constructor(detail?: string, options?: HttpExceptionOptions) {
    super(402, detail, { title: 'Payment Required', ...options });
  }
}

#The problem response

An exception becomes a response with the exception's status, these headers, and a JSON body:

HTTP
HTTP/1.1 409 Conflict
Content-Type: application/problem+json; charset=utf-8
X-Content-Type-Options: nosniff
JSON
{
  "sku": "sold-out",
  "type": "https://example.com/problems/sold-out",
  "title": "Conflict",
  "status": 409,
  "detail": "SKU sold-out is sold out",
  "instance": "/checkout/sold-out"
}
MemberValue
typeThe exception's type, about:blank by default.
titleThe exception's title.
statusThe HTTP status.
detailThe exception's detail; left out when there is none.
instanceThe request's path, without the query string.
extensionsEach member of extensions, at the top level of the object.

Extensions cannot replace the standard members: an extension named type, title, status or instance is overwritten by the real value, and so is one named detail when the exception has a detail. Headers from the exception's headers option are added to the response, both for 4xx and 5xx statuses. Headers set on ctx.res.headers are not, because they belong to the success response (see the response draft).

#Production masking

What a client sees depends on the development flag (KneadFactory.create(root, { development }), default process.env.NODE_ENV === 'development'), and on whether the error is a client error or a server error.

4xx HttpExceptions are sent in full in every mode: detail and extensions included. They describe what the client did wrong, so write their details for clients.

5xx HttpExceptions outside development carry only type, title, status, instance and a correlationId. The detail and the extensions are dropped, because they tend to describe internals. Headers such as Retry-After are kept. Thrown with new ServiceUnavailableException('Inventory is unavailable', { headers: { 'retry-after': '30' } }):

JSON
{
  "type": "about:blank",
  "title": "Service Unavailable",
  "status": 503,
  "instance": "/checkout/widget",
  "correlationId": "0f91fe5a-f818-4b25-8a83-69fee3de4e11"
}

Any other error (a TypeError, a database driver error, a value the handler returned that cannot be serialized as JSON) is an unknown error. Outside development the response is a bare 500:

JSON
{
  "type": "about:blank",
  "title": "Internal Server Error",
  "status": 500,
  "instance": "/checkout/crash",
  "correlationId": "b6c0afc5-1ec2-4a58-872c-2cc748d51e05"
}

In development, a 5xx HttpException keeps its detail and extensions, and an unknown error's response adds detail (the error's message) and stack. These are passed through credential redaction first, so redis://default:s3cret@cache:6379 in a message is sent as redis://***@cache:6379. Development mode is for your machine only: listen() warns when it is on and the server is bound to a non-loopback hostname.

#Logging and correlation ids

Every 5xx is logged at the error level through the application's LOGGER, under a new correlation id (a random UUID). The same id is the correlationId member of the response, so a user can report it and you can find the log line. 4xx responses are not logged.

Outside development, the log line holds only the error's Name: message, with credentials redacted and the message capped at 500 characters (LOGGED_MESSAGE_LENGTH). There is no stack, cause or property dump, since those often hold request data. With the default ConsoleLogger:

Output
[knead] 503 on POST /checkout/widget (correlation id 9019a25c-98bf-4b3d-8c6e-901c635091f7): ServiceUnavailableException: Inventory is unavailable
[knead] Unhandled error on GET /checkout/crash (correlation id b6c0afc5-1ec2-4a58-872c-2cc748d51e05): Error: Lost connection to redis://***@cache:6379

In development the message is logged without the : Name: message suffix, and the whole error is passed as the log entry's argument, redacted with redactValue() (credentials in its message, stack, cause chain and properties replaced by ***).

Redaction happens before the logger is called, so any LOGGER you install (see logging) receives redacted data.

#Logging errors yourself

To log an error the same way, inject ErrorLog and call log(message, error, level?). It returns the correlation id; level is 'error' (the default) or 'warn'. Above, the controller logs the underlying inventory error before throwing the 503, since the 503's own log line names only the 503. Outside development that logs:

Output
[knead] Reserving widget failed (correlation id 5a1d3e0c-2f0b-4d6e-9b8a-61f1c2a7e4d9): Error: connect ECONNREFUSED postgres://***@10.0.0.5:5432/inventory

logError(options, message, error, level?) is the same function without injection, for code that has a { logger, development } pair at hand.

#Custom error responses

To change how some errors are answered, write an exception filter (see exception filters). Inside one, defaultErrorResponse(error, ctx, logger, development) builds the response Knead would have sent, including the masking and the logging described above, so a filter can adjust it instead of rebuilding it:

no-store-errors.filter.ts
@Catch(Error)
export class NoStoreErrorsFilter implements ExceptionFilter<Error> {
  readonly #logger = inject(LOGGER);
  readonly #development = inject(HTTP_DEVELOPMENT);

  catch(error: Error, ctx: Ctx): Response {
    // The framework's own problem response (and logging), with one header added.
    const res = defaultErrorResponse(error, ctx, this.#logger, this.#development);
    return withHeaders(res, { 'cache-control': 'no-store' });
  }
}

problemResponse(problem, headers?) builds a problem response from a ProblemDetails object directly, with the application/problem+json content type and nosniff. Use exception.toProblem(instance?) to get an exception's problem body.

#Errors Knead raises itself

The framework uses the same format for the errors it detects:

SituationStatusDetail
No route matches the path404No route for GET /nope
The path matches, the method does not405PUT is not allowed on /cats/1 (with an Allow header)
Malformed JSON body400Malformed JSON body
Malformed multipart body400Malformed multipart body
The body cannot be read400Failed to read request body
Unsupported Content-Type415Unsupported content type: application/xml
Too many query parameters400The query string has more than 1000 parameters.
Too many form fields413The request body has more than 1000 fields.
Schema validation failed400Request validation failed (with issues)
A guard did not return true403none
Host not in allowedHosts421This server does not serve the requested host.

A request body larger than maxRequestBodySize is the exception: Bun rejects it with an empty 413 before Knead sees the request (see limits).

#Configuration errors

Mistakes in the application's setup, such as a duplicate route, an invalid guard or an invalid listen() option, are not HTTP errors. They throw HttpConfigError (a KneadError from @knead-js/core) when the application is bootstrapped or when listen() runs, never while a request is being served. See definition errors for the routing ones.

See the @knead-js/http reference for every exception class and helper.