Skip to content

grpc-js server: option to take the gRPC status from an Error.cause chain #3123

Open

Description

@hidingmyname

Is your feature request related to a problem? Please describe.

Location (grpc/grpc-node @ default branch ca7f83f):

  if (
    'code' in error &&
    typeof error.code === 'number' &&
    Number.isInteger(error.code)
  ) {
    status.code = error.code;

    if ('details' in error && typeof error.details === 'string') {
      status.details = error.details!;
    }
  }

Problem

A common Node.js pattern adds context to an error with the standard cause option, new Error('loading user failed', { cause: err }). If err is a status error ({ code: Status.NOT_FOUND, details: ... }) and a server handler passes the wrapper to its callback (or emits it on a stream), the client receives UNKNOWN with the wrapper's message. The original status code and details are lost.

This happens because serverErrorToStatus reads only the top-level code/details. Every handler error path uses it, for example the unary respond callback. The internal getErrorCode helper also reads only the top-level object. Today the only option is for applications to copy code/details/metadata onto every wrapper by hand, or to unwrap errors in every handler.

Reproduction

With inner = Object.assign(new Error('test description'), { code: Status.INTERNAL, details: 'test description' }) and wrapped = new Error('wrapped error', { cause: inner }):

serverErrorToStatus(wrapped).code  ->  2 (UNKNOWN), expected 13
getErrorCode(wrapped)              ->  null,        expected 13

The end-to-end client result described above follows from the respond code path. I did not run it as a separate client/server test.

Environment:

  • Commit: 6741e13; both files have the same blob on current master ca7f83f
  • Toolchain: Node v20.19.6, @grpc/grpc-js 1.13.4

Describe the solution you'd like

Already implemented in grpc/grpc-go

PR: grpc/grpc-go#6031 — "status: handle wrapped errors" (merged 2023-03-17T23:21:23Z, merge commit 66e35339a4b). It made status.Code and status.FromError handle wrapped errors, so a status inside a wrapped error chain is found. grpc-go does this by default; this request does not ask for that, see below.

How grpc-go fixed it:

--- a/status/status.go
+++ b/status/status.go
@@ -88,9 +89,8 @@ func FromError(err error) (s *Status, ok bool) {
 	if err == nil {
 		return nil, true
 	}
-	if se, ok := err.(interface {
-		GRPCStatus() *Status
-	}); ok {
+	var se interface{ GRPCStatus() *Status }
+	if errors.As(err, &se) {
 		return se.GRPCStatus(), true
 	}
 	return New(codes.Unknown, err.Error()), false

Suggested fix

Add an opt-in server option, grpc-node.resolve_error_cause. When it is set to 1, serverErrorToStatus takes the code, details and message from the first error in the cause chain with a valid integer code, as grpc-go does for wrapped errors, and a cause cycle ends the search. A top-level code always wins, explicit trailers and wrapper metadata take precedence over the inner metadata, and without the option nothing changes (getErrorCode has no callers and stays as it is):

Patch
diff --git a/packages/grpc-js/README.md b/packages/grpc-js/README.md
--- a/packages/grpc-js/README.md
+++ b/packages/grpc-js/README.md
@@ -71,6 +71,7 @@ Many channel arguments supported in `grpc` are not supported in `@grpc/grpc-js`.
   - `grpc-node.tls_enable_trace`
   - `grpc-node.retry_max_attempts_limit`
   - `grpc-node.flow_control_window`
+  - `grpc-node.resolve_error_cause`
   - `channelOverride`
   - `channelFactoryOverride`
 
diff --git a/packages/grpc-js/src/channel-options.ts b/packages/grpc-js/src/channel-options.ts
--- a/packages/grpc-js/src/channel-options.ts
+++ b/packages/grpc-js/src/channel-options.ts
@@ -67,6 +67,11 @@ export interface ChannelOptions {
   'grpc-node.retry_max_attempts_limit'?: number;
   'grpc-node.flow_control_window'?: number;
   'grpc.server_call_metric_recording'?: number;
+  /**
+   * If set to 1, a server takes the status of a handler error that has no
+   * valid integer `code` from the first error in its `cause` chain that has one
+   */
+  'grpc-node.resolve_error_cause'?: number;
   // eslint-disable-next-line @typescript-eslint/no-explicit-any
   [key: string]: any;
 }
@@ -105,7 +110,8 @@ export const recognizedOptions = {
   'grpc.lb.ring_hash.ring_size_cap': true,
   'grpc-node.retry_max_attempts_limit': true,
   'grpc-node.flow_control_window': true,
-  'grpc.server_call_metric_recording': true
+  'grpc.server_call_metric_recording': true,
+  'grpc-node.resolve_error_cause': true,
 };
 
 export function channelOptionsEqual(
diff --git a/packages/grpc-js/src/server-call.ts b/packages/grpc-js/src/server-call.ts
--- a/packages/grpc-js/src/server-call.ts
+++ b/packages/grpc-js/src/server-call.ts
@@ -59,25 +59,55 @@ export type ServerDuplexStream<RequestType, ResponseType> = ServerSurfaceCall &
   ObjectReadable<RequestType> &
   ObjectWritable<ResponseType> & { end: (metadata?: Metadata) => void };
 
+function hasStatusCode(error: object): boolean {
+  return (
+    'code' in error &&
+    typeof error.code === 'number' &&
+    Number.isInteger(error.code)
+  );
+}
+
+/**
+ * Find the first error in the `cause` chain of the given error, starting with
+ * the error itself, that has a valid status code. Returns the given error if
+ * there is none. A `cause` cycle ends the search.
+ */
+function resolveErrorCause(
+  error: ServerErrorResponse | ServerStatusResponse
+): ServerErrorResponse | ServerStatusResponse {
+  const seen = new Set<unknown>();
+  let current: unknown = error;
+  while (
+    typeof current === 'object' &&
+    current !== null &&
+    !seen.has(current)
+  ) {
+    if (hasStatusCode(current)) {
+      return current as ServerErrorResponse | ServerStatusResponse;
+    }
+    seen.add(current);
+    current = (current as { cause?: unknown }).cause;
+  }
+  return error;
+}
+
 export function serverErrorToStatus(
   error: ServerErrorResponse | ServerStatusResponse,
-  overrideTrailers?: Metadata | undefined
+  overrideTrailers?: Metadata | undefined,
+  useErrorCause = false
 ): PartialStatusObject {
+  const source = useErrorCause ? resolveErrorCause(error) : error;
   const status: PartialStatusObject = {
     code: Status.UNKNOWN,
-    details: 'message' in error ? error.message : 'Unknown Error',
-    metadata: overrideTrailers ?? error.metadata ?? null,
+    details: 'message' in source ? (source.message as string) : 'Unknown Error',
+    metadata: overrideTrailers ?? error.metadata ?? source.metadata ?? null,
   };
 
-  if (
-    'code' in error &&
-    typeof error.code === 'number' &&
-    Number.isInteger(error.code)
-  ) {
-    status.code = error.code;
+  if (hasStatusCode(source)) {
+    status.code = source.code!;
 
-    if ('details' in error && typeof error.details === 'string') {
-      status.details = error.details!;
+    if ('details' in source && typeof source.details === 'string') {
+      status.details = source.details!;
     }
   }
   return status;
@@ -191,14 +221,15 @@ export class ServerWritableStreamImpl<RequestType, ResponseType>
     private path: string,
     private call: ServerInterceptingCallInterface,
     public metadata: Metadata,
-    public request: RequestType
+    public request: RequestType,
+    useErrorCause = false
   ) {
     super({ objectMode: true });
     this.cancelled = false;
     this.trailingMetadata = new Metadata();
 
     this.on('error', err => {
-      this.pendingStatus = serverErrorToStatus(err);
+      this.pendingStatus = serverErrorToStatus(err, undefined, useErrorCause);
       this.end();
     });
   }
@@ -272,14 +303,15 @@ export class ServerDuplexStreamImpl<RequestType, ResponseType>
   constructor(
     private path: string,
     private call: ServerInterceptingCallInterface,
-    public metadata: Metadata
+    public metadata: Metadata,
+    useErrorCause = false
   ) {
     super({ objectMode: true });
     this.cancelled = false;
     this.trailingMetadata = new Metadata();
 
     this.on('error', err => {
-      this.pendingStatus = serverErrorToStatus(err);
+      this.pendingStatus = serverErrorToStatus(err, undefined, useErrorCause);
       this.end();
     });
   }
diff --git a/packages/grpc-js/src/server.ts b/packages/grpc-js/src/server.ts
--- a/packages/grpc-js/src/server.ts
+++ b/packages/grpc-js/src/server.ts
@@ -1466,14 +1466,15 @@ export class Server {
       | UntypedBidiStreamingHandler
   ): boolean {
     const { type } = handler;
+    const useErrorCause = this.options['grpc-node.resolve_error_cause'] === 1;
     if (type === 'unary') {
-      handleUnary(call, handler);
+      handleUnary(call, handler, useErrorCause);
     } else if (type === 'clientStream') {
-      handleClientStreaming(call, handler);
+      handleClientStreaming(call, handler, useErrorCause);
     } else if (type === 'serverStream') {
-      handleServerStreaming(call, handler);
+      handleServerStreaming(call, handler, useErrorCause);
     } else if (type === 'bidi') {
-      handleBidiStreaming(call, handler);
+      handleBidiStreaming(call, handler, useErrorCause);
     } else {
       return false;
     }
@@ -1979,7 +1980,8 @@ export class Server {
 
 async function handleUnary<RequestType, ResponseType>(
   call: ServerInterceptingCallInterface,
-  handler: UnaryHandler<RequestType, ResponseType>
+  handler: UnaryHandler<RequestType, ResponseType>,
+  useErrorCause: boolean
 ): Promise<void> {
   let stream: ServerUnaryCall<RequestType, ResponseType>;
 
@@ -1990,7 +1992,7 @@ async function handleUnary<RequestType, ResponseType>(
     flags?: number
   ) {
     if (err) {
-      call.sendStatus(serverErrorToStatus(err, trailer));
+      call.sendStatus(serverErrorToStatus(err, trailer, useErrorCause));
       return;
     }
     call.sendMessage(value, () => {
@@ -2034,7 +2036,8 @@ async function handleUnary<RequestType, ResponseType>(
         handler.path,
         call,
         requestMetadata,
-        requestMessage
+        requestMessage,
+        useErrorCause
       );
       try {
         handler.func(stream, respond);
@@ -2065,7 +2068,8 @@ async function handleUnary<RequestType, ResponseType>(
 
 function handleClientStreaming<RequestType, ResponseType>(
   call: ServerInterceptingCallInterface,
-  handler: ClientStreamingHandler<RequestType, ResponseType>
+  handler: ClientStreamingHandler<RequestType, ResponseType>,
+  useErrorCause: boolean
 ): void {
   let stream: ServerReadableStream<RequestType, ResponseType>;
 
@@ -2076,7 +2080,7 @@ function handleClientStreaming<RequestType, ResponseType>(
     flags?: number
   ) {
     if (err) {
-      call.sendStatus(serverErrorToStatus(err, trailer));
+      call.sendStatus(serverErrorToStatus(err, trailer, useErrorCause));
       return;
     }
     call.sendMessage(value, () => {
@@ -2090,7 +2094,12 @@ function handleClientStreaming<RequestType, ResponseType>(
 
   call.start({
     onReceiveMetadata(metadata) {
-      stream = new ServerDuplexStreamImpl(handler.path, call, metadata);
+      stream = new ServerDuplexStreamImpl(
+        handler.path,
+        call,
+        metadata,
+        useErrorCause
+      );
       try {
         handler.func(stream, respond);
       } catch (err) {
@@ -2127,7 +2136,8 @@ function handleClientStreaming<RequestType, ResponseType>(
 
 function handleServerStreaming<RequestType, ResponseType>(
   call: ServerInterceptingCallInterface,
-  handler: ServerStreamingHandler<RequestType, ResponseType>
+  handler: ServerStreamingHandler<RequestType, ResponseType>,
+  useErrorCause: boolean
 ): void {
   let stream: ServerWritableStream<RequestType, ResponseType>;
 
@@ -2163,7 +2173,8 @@ function handleServerStreaming<RequestType, ResponseType>(
         handler.path,
         call,
         requestMetadata,
-        requestMessage
+        requestMessage,
+        useErrorCause
       );
       try {
         handler.func(stream);
@@ -2195,13 +2206,19 @@ function handleServerStreaming<RequestType, ResponseType>(
 
 function handleBidiStreaming<RequestType, ResponseType>(
   call: ServerInterceptingCallInterface,
-  handler: BidiStreamingHandler<RequestType, ResponseType>
+  handler: BidiStreamingHandler<RequestType, ResponseType>,
+  useErrorCause: boolean
 ): void {
   let stream: ServerDuplexStream<RequestType, ResponseType>;
 
   call.start({
     onReceiveMetadata(metadata) {
-      stream = new ServerDuplexStreamImpl(handler.path, call, metadata);
+      stream = new ServerDuplexStreamImpl(
+        handler.path,
+        call,
+        metadata,
+        useErrorCause
+      );
       try {
         handler.func(stream);
       } catch (err) {

Verified on 6741e13 (Node.js v22.23.4, mocha 10.8.2): with true passed as the new third argument to serverErrorToStatus, the reproduction above returns 13 with this patch, and a new client/server test with 'grpc-node.resolve_error_cause': 1 gets INTERNAL and the inner details on unary, client-streaming, server-streaming and bidi calls. Without the patch both tests fail. The same test still gets UNKNOWN when the option is unset. No other test results change.

I can submit a PR for this.

Describe alternatives you've considered

  • Copying code onto each wrapper works, but every wrapping layer has to know about gRPC.
  • A server interceptor could unwrap errors. But the handler callback error goes through serverErrorToStatus inside the library, so the interceptor would have to re-implement that conversion.

Additional context

Related: I searched issues and PRs (error cause status, "cause" serverErrorToStatus OR getErrorCode, wrapped error status code UNKNOWN, "error.cause" OR "Error cause") and found no existing request. The hits (#2899, #290, #2033 and others) are unrelated.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions