Repository navigation
Description
Is your feature request related to a problem? Please describe.
Location (grpc/grpc-node @ default branch ca7f83f):
packages/grpc-js/src/server-call.ts:62-84—serverErrorToStatuspackages/grpc-js/src/server.ts:1986-1995— unaryrespondcallbackpackages/grpc-js/src/error.ts:26-37—getErrorCode
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 currentmasterca7f83f - Toolchain: Node v20.19.6,
@grpc/grpc-js1.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()), falseSuggested 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
codeonto 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
serverErrorToStatusinside 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