From 896ea091bcb7b10ed000d0285e11feb23a58f6af Mon Sep 17 00:00:00 2001 From: kyungseopk1m Date: Tue, 7 Jul 2026 15:17:08 +0900 Subject: [PATCH 1/2] fix(bigtable): avoid self-referential error on RPC-level mutate failure When mutate hits an RPC-level failure with entries still pending and no per-entry mutation errors, mutateInternal set err.errors to an array containing err itself. The resulting cycle (err.errors[i] === err) makes the error impossible to serialize with tools that follow the AggregateError convention of recursing into errors, such as pino, which throws RangeError: Maximum call stack size exceeded. Push a fresh error carrying the RPC-level code and message for each pending entry instead of err itself. Fixes #8075 --- handwritten/bigtable/src/utils/mutateInternal.ts | 16 +++++++++++++--- handwritten/bigtable/test/table.ts | 5 +++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/handwritten/bigtable/src/utils/mutateInternal.ts b/handwritten/bigtable/src/utils/mutateInternal.ts index f068543cfe90..5ce55a6c3b1f 100644 --- a/handwritten/bigtable/src/utils/mutateInternal.ts +++ b/handwritten/bigtable/src/utils/mutateInternal.ts @@ -146,13 +146,23 @@ export function mutateInternal( a status code, the RPC level failure error code will be used as the entry failure code. */ - (err as ServiceError & {errors?: ServiceError[]}).errors = + const rpcError = err; + (rpcError as ServiceError & {errors?: ServiceError[]}).errors = mutationErrors.concat( [...pendingEntryIndices] .filter(index => !mutationErrorsByEntryIndex.has(index)) - .map(() => err), + .map((): ServiceError => { + // Carry the RPC-level code and message on a fresh error rather + // than pushing `rpcError` into its own `errors` array. A + // self-reference makes the returned error impossible to + // serialize for tools that follow the AggregateError convention + // of recursing into `errors` (e.g. pino). + const entryError = new Error(rpcError.message) as ServiceError; + entryError.code = rpcError.code; + return entryError; + }), ); - collectMetricsCallback(err, err); + collectMetricsCallback(rpcError, rpcError); return; } collectMetricsCallback(null, null); diff --git a/handwritten/bigtable/test/table.ts b/handwritten/bigtable/test/table.ts index c10e89145d4e..36f31d04825c 100644 --- a/handwritten/bigtable/test/table.ts +++ b/handwritten/bigtable/test/table.ts @@ -2889,6 +2889,11 @@ describe('Bigtable/Table', () => { assert.strictEqual(err.errors.length, 1); assert.strictEqual(err.errors[0].code, 3); assert.strictEqual(err.errors[0].message, 'not retryable'); + // Regression test for #8075: the per-entry error must not be + // `err` itself, otherwise `err.errors` references `err` and the + // error can no longer be serialized (e.g. by pino). + assert.notStrictEqual(err.errors[0], err); + assert.doesNotThrow(() => JSON.stringify(err)); done(); } catch (e) { done(e); From 73fd88c6efb9eb0b4448f045e8106cf360c58980 Mon Sep 17 00:00:00 2001 From: kyungseopk1m Date: Tue, 7 Jul 2026 15:47:23 +0900 Subject: [PATCH 2/2] fix(bigtable): preserve gRPC metadata and details on per-entry mutate error The fix for #8075 replaced the self-referential error with a fresh error carrying only the RPC-level code and message. The former self-reference also exposed the metadata and details of the gRPC failure on each entry error; restore that debugging context on the fresh error so downstream consumers do not lose it. The regression test now seeds a non-empty metadata and details on the failure and asserts both are carried through. Fixes #8075 --- handwritten/bigtable/src/utils/mutateInternal.ts | 8 ++++++++ handwritten/bigtable/test/table.ts | 12 +++++++++++- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/handwritten/bigtable/src/utils/mutateInternal.ts b/handwritten/bigtable/src/utils/mutateInternal.ts index 5ce55a6c3b1f..d9180a2746a1 100644 --- a/handwritten/bigtable/src/utils/mutateInternal.ts +++ b/handwritten/bigtable/src/utils/mutateInternal.ts @@ -159,6 +159,14 @@ export function mutateInternal( // of recursing into `errors` (e.g. pino). const entryError = new Error(rpcError.message) as ServiceError; entryError.code = rpcError.code; + // Preserve the gRPC debugging context that the former + // self-reference carried for each entry. + if (rpcError.metadata) { + entryError.metadata = rpcError.metadata; + } + if (rpcError.details) { + entryError.details = rpcError.details; + } return entryError; }), ); diff --git a/handwritten/bigtable/test/table.ts b/handwritten/bigtable/test/table.ts index 36f31d04825c..fc2eed93a341 100644 --- a/handwritten/bigtable/test/table.ts +++ b/handwritten/bigtable/test/table.ts @@ -19,7 +19,7 @@ import * as proxyquire from 'proxyquire'; import * as pumpify from 'pumpify'; import * as sinon from 'sinon'; import {PassThrough, Writable, Duplex} from 'stream'; -import {ServiceError} from 'google-gax'; +import {grpc, ServiceError} from 'google-gax'; import * as inst from '../src/instance'; import {ChunkTransformer} from '../src/chunktransformer.js'; @@ -2861,6 +2861,9 @@ describe('Bigtable/Table', () => { ]; const unretriableError = new Error('not retryable') as ServiceError; unretriableError.code = 3; // INVALID_ARGUMENT + unretriableError.metadata = new grpc.Metadata(); + unretriableError.metadata.set('x-goog-error', 'context'); + unretriableError.details = 'invalid argument details'; emitters = [ ((stream: Writable) => { stream.emit('data', { @@ -2894,6 +2897,13 @@ describe('Bigtable/Table', () => { // error can no longer be serialized (e.g. by pino). assert.notStrictEqual(err.errors[0], err); assert.doesNotThrow(() => JSON.stringify(err)); + // The per-entry error keeps the gRPC metadata and details from + // the RPC-level failure so debugging context isn't lost. + assert.strictEqual(err.errors[0].metadata, err.metadata); + assert.strictEqual( + err.errors[0].details, + 'invalid argument details', + ); done(); } catch (e) { done(e);