Skip to content

Filter-stack rejections produce empty (code/details: undefined) status when the rejection value isn't a StatusObject #3051

@gavinsharp

Description

@gavinsharp

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_callload_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:

  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.
  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:

(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.

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