InteractiveFrameworks

Errors across the wire

What a thrown error becomes on the other side of a transport: RpcException for errors the caller can act on, an exception filter that turns a domain error into one, and a gateway that turns every reply's error into the right HTTP status.

What you'll learn

  • Throw RpcException for an error the caller should see, and know what it becomes on the wire: an object as it is, a string as { status: 'error', message }
  • Know why any other error, an HttpException included, reaches the caller only as 'Internal server error'
  • Write an RpcExceptionFilter whose catch() returns an erroring Observable, and bind it with @UseFilters
  • Translate reply errors into HTTP statuses at the gateway with an RxJS operator, including a 502 for failures the service did not explain

In one HTTP app, a NotFoundException thrown anywhere becomes a 404 with a readable body, and that is the end of it. Split the app in two and the error has to travel. It is thrown in the cats service, serialized, carried over TCP, and handed to the gateway, which must still answer the visitor with the right status. Nothing about that journey is automatic. This lesson follows an error from the throw in the service to the status the visitor sees.

What a microservice sends back

When a message handler throws, Nest's RPC exception layer decides what the caller receives, and it knows exactly one kind of error: RpcException.

throw new RpcException({ status: 403, message: 'This shelter is closed' });
// the caller receives { status: 403, message: 'This shelter is closed' }

throw new RpcException('This shelter is closed');
// the caller receives { status: 'error', message: 'This shelter is closed' }

An object is sent as it is, so you choose its shape; the status inside is only a number both sides agreed on. A string is wrapped with status: 'error'. Every other error is sent as { status: 'error', message: 'Internal server error' }, so the caller cannot tell what went wrong. That includes your own Error subclasses and Nest's own NotFoundException. HTTP exceptions mean nothing to a transport. A plain Error is at least logged in the service's console. An HttpException is not even logged: it disappears into "Internal server error" with no trace anywhere.

Exception filters for messages

Services keep throwing what makes sense for the domain, and a filter translates at the edge, as on HTTP. The difference is that a microservice filter's catch() does not write a response. It returns an Observable, and the error that Observable carries is what the caller receives:

@Catch(ShelterClosedError)
export class ShelterClosedFilter implements RpcExceptionFilter<ShelterClosedError> {
  catch(exception: ShelterClosedError, host: ArgumentsHost): Observable<never> {
    return throwError(() => ({ status: 403, message: exception.message }));
  }
}

RpcExceptionFilter comes from @nestjs/common. Bind the filter as on HTTP: @UseFilters(new ShelterClosedFilter()) on a handler or on the controller. If catch() returns a value instead of an error (of(...)), the caller receives it as a successful reply. To adjust Nest's default handling instead of replacing it, extend BaseRpcExceptionFilter and call super.catch().

At the gateway

On the gateway's side the reply's Observable errors with that plain object, not an Error, and not an HttpException. Nest's HTTP layer does not know it, so the visitor gets a 500 and the console logs Object(2) { status: 404, message: "Cat 9 not found" }. The gateway has to translate. An RxJS operator does it once for every call:

export function toProblem<T>(): MonoTypeOperatorFunction<T> {
  return catchError((err) => throwError(() => new HttpException(err.message, err.status)));
}

this.shelters.send({ cmd: 'open' }, id).pipe(toProblem());

A gateway also meets failures the service never described: a string such as There is no matching message handler defined in the remote service., or { status: 'error', message: 'Internal server error' }. Neither carries a number. 502 Bad Gateway is the honest answer: the server behind the gateway failed. 500 would blame the gateway itself.

Your task

The service can rename a cat now. CatsService.rename() throws a NameTakenError (a plain Error subclass) when another cat has the name. The gateway has a PATCH /cats/:id route, and a GET /cats/:id/history route that was deployed before the service learned that pattern.

  1. In the service's CatsController, an unknown id is an error the caller can act on: throw an RpcException carrying { status: 404, message: 'Cat <id> not found' }, both for { cmd: 'find-one' } and for { cmd: 'rename' }.
  2. Write NameTakenFilter: an RpcExceptionFilter for NameTakenError whose error is { status: 409, message: <the error's message> }. Bind it to the rename handler.
  3. In gateway/rpc-errors.ts, write toHttpError(). An error with a numeric status becomes an HttpException with that status and its message. A string, or an error without a number, becomes a 502 whose message is the string or the error's message. Pipe every send() in the gateway through it. GET /cats/:id no longer needs its null check.

When it fails

  • GET /cats/9 answers 502 Internal server error, and the service logged nothing: it threw NotFoundException, which a transport does not understand. Throw RpcException.
  • 502 with the right message but not the right status: new RpcException('Cat 9 not found') sends a string, which carries status: 'error'. Pass an object with a numeric status.
  • The rename is 502 and the service logs ERROR [RpcExceptionsHandler] NameTakenError: There is already a cat named Tom: the filter is not bound, or @Catch() names another class.
  • The rename answers 200 with { "status": 409, ... }: catch() returned a value. Return throwError(() => ...).
  • 500 at the gateway and ERROR [ExceptionsHandler] Object(2) { status: 404, ... }: the reply's error reached Nest's HTTP layer untranslated. A call is missing its operator.

Remember

  • Only RpcException crosses the wire intact: an object as it is, a string as { status: 'error', message }.
  • Everything else, HttpException included, arrives as Internal server error.
  • A microservice filter returns an erroring Observable; bind it with @UseFilters().
  • The gateway translates reply errors into HTTP, with 502 for failures the service did not explain.
Stuck? Show a hint

Service: throw new RpcException({ status: 404, message }) where a cat is missing; @Catch(NameTakenError) on a class implementing RpcExceptionFilter from @nestjs/common, whose catch() returns throwError(() => ({ status: 409, message })); @UseFilters(new NameTakenFilter()) on the rename handler. Gateway: toHttpError() returns catchError(err => throwError(() => new HttpException(message, status))), and every send() pipes it.