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
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions