Filter-stack rejections produce empty (code/details: undefined) status when the rejection value isn't a StatusObject
- Dominant language
- TypeScript
- Stars
- 4.8k
- Forks
- 716
- Avg merge
- 2d 3h
- Merged PRs (30d)
- 10
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)`:
```ts
// 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`](https://github.com/grpc/grpc-node/blob/master/packages/grpc-js/src/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:
1. Next.js 16 application with `output: "standalone"` (Turbopack).
2. `@google-cloud/firestore@8.3.0`, `@grpc/grpc-js@1.13.4`.
3. Force `@protobufjs/inquire@1.1.1` resolution (default with `protobufjs@7.5.6+`) — see [protobufjs/protobuf.js#2214](https://github.com/protobufjs/protobuf.js/issues/2214).
4. `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`:
```ts
(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.
Contributor guide
Assessment
This issue has not been assessed yet.