diff --git a/handwritten/bigtable/src/utils/mutateInternal.ts b/handwritten/bigtable/src/utils/mutateInternal.ts index f068543cfe90..d9180a2746a1 100644 --- a/handwritten/bigtable/src/utils/mutateInternal.ts +++ b/handwritten/bigtable/src/utils/mutateInternal.ts @@ -146,13 +146,31 @@ 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; + // 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; + }), ); - 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..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', { @@ -2889,6 +2892,18 @@ 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)); + // 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);