InteractiveFrameworks

Exception filters

Refuse a message the socket way: WsException with a message or an object, what every other error becomes on the wire, and a filter that sends the HTTP API's exceptions to a socket with their message instead of hiding them.

What you'll learn

  • Throw WsException with a message or an object, and read the exception event the client receives: status, message and the cause
  • Know what every other error becomes, an HttpException included: Internal server error, with the details kept on the server
  • Write a filter that extends BaseWsExceptionFilter, catches one kind of exception and hands the default filter a WsException
  • Bind a filter to a gateway with @UseFilters(), and know a global filter does not reach gateways

Until now a refusal was a normal answer: { error: 'Cat 9 not found' } in the acknowledgement. That works, but every client has to check every answer for an error key, and a handler that is not acknowledged (an event, a broadcast) has nowhere to put one. Over HTTP an exception becomes a status code and a body. Over a socket there is no status line, so Nest gives WebSockets an exceptions layer of their own: throw, and the client receives an exception event.

WsException

The exception to throw from a gateway is WsException from @nestjs/websockets. Nest catches it and sends the client that sent the message an exception event. Given a message:

throw new WsException('Kennel C is closed');

the client receives:

{ "status": "error", "message": "Kennel C is closed", "cause": { "pattern": "kennels:visit", "data": { "kennel": "C" } } }

cause repeats the event and payload that failed, so a client with many messages in flight can tell which one this was. Given an object, Nest sends exactly the object, with no status and no cause, which leaves its shape to you:

throw new WsException({ code: 'KENNEL_CLOSED', kennel: 'C' });

A client listens with socket.on('exception', ...). The exception is not an acknowledgement, so a client waiting with emitWithAck() gets no answer; pair the two with a timeout, socket.timeout(5000).emitWithAck(...), or answer refusals in the acknowledgement as before when the client must hear back either way.

Everything else

Any other error reaches the client as { "status": "error", "message": "Internal server error", "cause": … }. That includes a TypeError from a bug and a database driver's connect ECONNREFUSED, which is what you want, since their messages are for the server's log. It also includes Nest's own HTTP exceptions. A NotFoundException thrown by a service that the REST API shares has a perfectly good message, written for clients, and over a socket it becomes Internal server error, because HttpException is not a WsException.

Filters

A WebSocket exception filter has the same shape as an HTTP one, and BaseWsExceptionFilter is the default behaviour, to extend instead of rewriting. A filter that catches one kind of exception and hands the default a WsException changes what that kind looks like and nothing else:

@Catch(EntityNotFoundError)
export class EntityNotFoundFilter extends BaseWsExceptionFilter {
  catch(exception: EntityNotFoundError, host: ArgumentsHost) {
    super.catch(new WsException('That record does not exist'), host);
  }
}

@Catch() names what the filter handles; with no argument it handles everything. When you need the socket itself, host.switchToWs() gives getClient(), getData() and getPattern(). BaseWsExceptionFilter's constructor also takes options: { includeCause: false } leaves cause out of every exception.

Bind a filter with @UseFilters() on a handler or on the gateway class. Unlike over HTTP, a global filter, from app.useGlobalFilters() or APP_FILTER, does not apply to gateways at all.

Your task

CatsService is shared with the REST API: findOneOrFail() throws NotFoundException, and the vet's database is down, so vetRecords() throws a plain Error with the host name in it.

  1. cats:find for a cat the shelter does not have throws a WsException saying Cat 9 not found.
  2. cats:adopt for a cat already adopted throws a WsException carrying { code: 'ALREADY_ADOPTED', name }.
  3. Write HttpToWsFilter: it catches Nest's HTTP exceptions, and only those, and sends each the way the default filter sends a WsException with the same message.
  4. Use the filter for every handler of the gateway.

Then emit cats:records and read what arrives, and what does not.

When it fails

  • "message":"Internal server error" for cats:adopt with id 9: the filter is not bound, or catches something other than HttpException, so the NotFoundException takes the default path.
  • "message":"connect ECONNREFUSED vet-db.internal:5432 …" reaches the client: the filter catches everything, a missing @Catch(HttpException) included, and turns the database error into a WsException with its message.
  • ada received nothing for an unknown cat: find still returns undefined instead of throwing, and undefined is never sent.
  • {"message":"Cat 9 not found","error":"Not Found","statusCode":404}, with no status and no cause: the filter built its WsException from exception.getResponse(), an object, which is sent as it is. Use exception.message.

Remember

  • Throw WsException: a message becomes { status, message, cause }, an object is sent as it is.
  • Every other error, HttpException included, reaches the client as Internal server error.
  • Extend BaseWsExceptionFilter, catch one kind, hand super.catch() a WsException.
  • Global filters do not reach gateways; bind with @UseFilters() on the gateway.
Stuck? Show a hint

cats.gateway.ts: throw new WsException(`Cat ${id} not found`) in find; throw new WsException({ code: 'ALREADY_ADOPTED', name: cat.name }) in adopt; @UseFilters(new HttpToWsFilter()) on the class. http-to-ws.filter.ts: @Catch(HttpException) on the class, catch(exception: HttpException, host) { super.catch(new WsException(exception.message), host); }.