Repository navigation
Description
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.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: 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.
Activity
murgatroid99 commentedon Sep 17, 2026
This is fixed in version 1.14.5