Problem description
When the filter stack in resolving-call.ts's sendMessageOnChild rejects with a non-status error (a raw Error / TypeError / etc.), the rejection handler reads .code and .details off the error object — both undefined — and calls cancelWithStatus(undefined, undefined):
// packages/grpc-js/src/resolving-call.ts
this.filterStack.sendMessage(Promise.resolve({message, flags})).then(
filteredMessage => { ... },
(status) => { this.cancelWithStatus(status.code, status.details); }
);
The resulting status has no code, no details, and an empty Metadata map, and propagates through retrying_call → load_balancing_call → the application as Error: undefined undefined: undefined.
This makes the originating error effectively invisible. Even with GRPC_TRACE=all GRPC_VERBOSITY=DEBUG the only output is cancelWithStatus code: undefined details: "undefined" with no reference to the underlying thrown error. Compare to the response-deserialize path in client-interceptors.ts, which correctly handles non-status throws by synthesizing Status.INTERNAL with getErrorMessage(e).
Reproduction steps
The shortest known path is via #3050: trigger that, and the thrown TypeError flows directly into this rejection handler. Concrete steps:
- Next.js 16 application with
output: "standalone" (Turbopack).
@google-cloud/firestore@8.3.0, @grpc/grpc-js@1.13.4.
- Force
@protobufjs/inquire@1.1.1 resolution (default with protobufjs@7.5.6+) — see protobufjs/protobuf.js#2214.
next build and run. Trigger any Firestore call.
Expected: the error returned to the caller reflects the underlying cause.
Actual: callback receives Error: undefined undefined: undefined with { code: undefined, details: undefined, metadata: Metadata { internalRepr: Map(0) {}, options: {} } }. The originating TypeError: message.copy is not a function is nowhere in the stack, log, or error message.
Independently, the same code path is reachable by registering any custom filter whose sendMessage rejects with a raw Error rather than a {code, details} status object.
Environment
- OS: macOS 15.6 arm64 (local repro); Linux amd64 (Google Cloud Run production)
- Node: v24
- Node installation: project
engines.node = "24", npm 11
- Package:
@grpc/grpc-js@1.13.4
- Adjacent:
google-gax@5.0.6, @google-cloud/firestore@8.3.0
Additional context
Suggested fix: mirror the deserialize-error path in client-interceptors.ts. In the rejection handler, detect whether the rejection value has a numeric code; if not, synthesize Status.INTERNAL:
(status) => {
if (typeof status?.code === 'number') {
this.cancelWithStatus(status.code, status.details);
} else {
this.cancelWithStatus(
Status.INTERNAL,
`Request message filter error: ${getErrorMessage(status)}`
);
}
}
Why this matters independent of #3050: a thrown TypeError (or any non-status error) in the filter stack should not produce an undebuggable empty status. Any future filter — compression handler, custom interceptor, etc. — throwing a non-status error would hit the same silent path.
Impact in our deployment: combined with #3050, this bug caused a production outage, because the symptom (empty status, no stack into grpc-js, repeated cycles under gax retry) gave no hint about the underlying TypeError. With this fix alone — even leaving #3050 unfixed — the same incident would have surfaced as Status.INTERNAL: Request message filter error: TypeError: message.copy is not a function, and we would have identified the trigger quickly.
Problem description
When the filter stack in
resolving-call.ts'ssendMessageOnChildrejects with a non-status error (a rawError/TypeError/ etc.), the rejection handler reads.codeand.detailsoff the error object — bothundefined— and callscancelWithStatus(undefined, undefined):The resulting status has no code, no details, and an empty
Metadatamap, and propagates throughretrying_call→load_balancing_call→ the application asError: undefined undefined: undefined.This makes the originating error effectively invisible. Even with
GRPC_TRACE=all GRPC_VERBOSITY=DEBUGthe only output iscancelWithStatus code: undefined details: "undefined"with no reference to the underlying thrown error. Compare to the response-deserialize path inclient-interceptors.ts, which correctly handles non-status throws by synthesizingStatus.INTERNALwithgetErrorMessage(e).Reproduction steps
The shortest known path is via #3050: trigger that, and the thrown
TypeErrorflows directly into this rejection handler. Concrete steps:output: "standalone"(Turbopack).@google-cloud/firestore@8.3.0,@grpc/grpc-js@1.13.4.@protobufjs/inquire@1.1.1resolution (default withprotobufjs@7.5.6+) — see protobufjs/protobuf.js#2214.next buildand run. Trigger any Firestore call.Expected: the error returned to the caller reflects the underlying cause.
Actual: callback receives
Error: undefined undefined: undefinedwith{ code: undefined, details: undefined, metadata: Metadata { internalRepr: Map(0) {}, options: {} } }. The originatingTypeError: message.copy is not a functionis nowhere in the stack, log, or error message.Independently, the same code path is reachable by registering any custom filter whose
sendMessagerejects with a rawErrorrather than a{code, details}status object.Environment
engines.node = "24", npm 11@grpc/grpc-js@1.13.4google-gax@5.0.6,@google-cloud/firestore@8.3.0Additional context
Suggested fix: mirror the deserialize-error path in
client-interceptors.ts. In the rejection handler, detect whether the rejection value has a numericcode; if not, synthesizeStatus.INTERNAL:Why this matters independent of #3050: a thrown
TypeError(or any non-status error) in the filter stack should not produce an undebuggable empty status. Any future filter — compression handler, custom interceptor, etc. — throwing a non-status error would hit the same silent path.Impact in our deployment: combined with #3050, this bug caused a production outage, because the symptom (empty status, no stack into grpc-js, repeated cycles under gax retry) gave no hint about the underlying
TypeError. With this fix alone — even leaving #3050 unfixed — the same incident would have surfaced asStatus.INTERNAL: Request message filter error: TypeError: message.copy is not a function, and we would have identified the trigger quickly.