Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 83 additions & 2 deletions core/packages/gax/src/observability/errorUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/

import {Attributes, Span} from '@opentelemetry/api';
import * as protobuf from 'protobufjs';
import {GoogleError} from '../googleError';
import {Status} from '../status';
/**
Expand Down Expand Up @@ -798,9 +799,46 @@ export function isServerSideError(
return resolveServerErrorCode(e, rpcType) !== undefined;
}

/**
* Returns the bytes of binary data: an ArrayBuffer, or a view of one such as a Buffer
* or Uint8Array.
*
* @param {unknown} value - The value to check.
* @returns {Uint8Array | undefined} The bytes of `value`, or undefined if it is not binary data.
*/
function toBytes(value: unknown): Uint8Array | undefined {
if (ArrayBuffer.isView(value)) {
return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
}
if (value instanceof ArrayBuffer) {
return new Uint8Array(value);
}
return undefined;
}

/**
* Encodes bytes as base64, using Buffer when it is available and a pure JavaScript
* encoder otherwise (e.g. in browsers).
*
* @param {Uint8Array} bytes - The bytes to encode.
* @returns {string} The base64 encoding of `bytes`.
*/
function encodeBase64(bytes: Uint8Array): string {
if (typeof Buffer !== 'undefined') {
return Buffer.from(
bytes.buffer,
bytes.byteOffset,
bytes.byteLength,
).toString('base64');
}
return protobuf.util.base64.encode(bytes, 0, bytes.length);
}

/**
* Safely converts a value to a JSON string without throwing exceptions on
* circular references, BigInt values, or non-serializable properties.
* circular references, BigInt values, or non-serializable properties. Binary data
* (e.g. a Buffer or Uint8Array) is serialized as a base64 string rather than one
* JSON entry per byte.
*
* @param {unknown} value - The value to serialize.
* @returns {string | undefined} The serialized JSON string, or undefined if `value` is undefined or unserializable.
Expand All @@ -813,13 +851,24 @@ export function safeJsonStringify(value: unknown): string | undefined {
const ancestors: unknown[] = [];
return JSON.stringify(
value,
function (this: unknown, _key: string, val: unknown) {
function (this: unknown, key: string, val: unknown) {
if (typeof val === 'bigint') {
return val.toString();
}
if (typeof val !== 'object' || val === null) {
return val;
}
// Encode binary data as base64. Buffer#toJSON runs before this replacer and
// turns a Buffer into {type: 'Buffer', data: [...]}, so in that case the
// original Buffer is read back from the holder.
const bytes = toBytes(
(val as {type?: unknown}).type === 'Buffer'
? (this as Record<string, unknown>)[key]
: val,
);
if (bytes) {
return encodeBase64(bytes);
}
Comment on lines +861 to +871

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Now that safeJsonStringify (lines 840-850) automatically base64-encodes Buffer, Uint8Array, and ArrayBuffer values in its replacer, isBuffer (line 778) and the 20-line cleanMap Buffer-encoding loop in resolveServerExceptionDetails in the same file are redundant; resolveServerExceptionDetails can pass map directly to safeJsonStringify(map).

// Track object ancestry to replace circular references.
if (ancestors.includes(this)) {
while (
Expand All @@ -845,6 +894,38 @@ export function safeJsonStringify(value: unknown): string | undefined {
}
}

/**
* Resolves the `response.payload` attribute: the full response payload of a call,
* serialized as JSON without truncation or redaction. String payloads are returned
* as-is and binary payloads (e.g. a Buffer or Uint8Array) as base64, as are binary
* values nested in the response (e.g. `bytes` fields). Prefers `rawResponse`, the
* unprocessed response gax provides for paginated and long-running calls (the full
* page response or the raw Operation), over the processed `response`.
*
* @param {unknown} response - The response passed to the callback or resolved by the promise.
* @param {unknown} [rawResponse] - The raw (unprocessed) response, when gax provides one.
* @returns {string | undefined} The serialized payload, or undefined if there is no response.
*/
export function resolveResponsePayload(
response: unknown,
rawResponse?: unknown,
): string | undefined {
const payload = rawResponse ?? response;
if (payload === undefined || payload === null) {
return undefined;
}
// TODO(security): The payload is returned in full because `response.payload` is defined
// as the raw response, so it can contain sensitive data (e.g. secret values or access
// tokens). Only emit it when logging is explicitly enabled (see `checkLoggingEnabled`),
// and consider redacting known sensitive fields.
if (typeof payload === 'string') {
return payload;
}
// Binary payloads are base64 encoded and, like strings, not wrapped in JSON quotes.
const bytes = toBytes(payload);
return bytes ? encodeBase64(bytes) : safeJsonStringify(payload);
}
Comment on lines +909 to +927

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Consider removing the unused rawResponse?: unknown parameter on resolveResponsePayload (response.payload is an L4 error-only attribute in the Milestone 2 Design Doc and never receives paginated/LRO rawResponse), avoiding allocating intermediate Uint8Array and Buffer views in toBytes / encodeBase64 when value is already a Buffer or Uint8Array, and checking Array.isArray((val as {data?: unknown}).data) alongside (val as {type?: unknown}).type === 'Buffer' in safeJsonStringify (line 844) before re-reading this[key].


/**
* Extracts the server-side error details message attached by GFE/backend on
* server-side errors, checking the error and its `.cause` chain and falling
Expand Down
104 changes: 104 additions & 0 deletions core/packages/gax/test/unit/errorUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import {
resolveExceptionMessage,
resolveExceptionStacktrace,
resolveResponseStatusCode,
resolveResponsePayload,
resolveServerExceptionDetails,
recordExceptionEvent,
isPreConnectionFailure,
Expand Down Expand Up @@ -1121,6 +1122,109 @@ describe('errorUtil', () => {
};
assert.strictEqual(safeJsonStringify(badObj), undefined);
});

it('serializes binary data as base64 instead of one entry per byte', () => {
// Buffers reach the replacer already converted by Buffer#toJSON, unlike other
// binary data such as Uint8Arrays, so both are covered.
assert.strictEqual(
safeJsonStringify({
buffer: Buffer.from('hello'),
list: [new Uint8Array([1, 2, 3])],
}),
'{"buffer":"aGVsbG8=","list":["AQID"]}',
);
// Objects that only look like a serialized Buffer are left as-is.
assert.strictEqual(
safeJsonStringify({type: 'Buffer', data: [1, 2]}),
'{"type":"Buffer","data":[1,2]}',
);
});
});

describe('resolveResponsePayload', () => {
it('serializes the full response as JSON and returns string payloads as-is', () => {
assert.strictEqual(
resolveResponsePayload({
name: 'projects/p/secrets/s',
labels: {env: 'prod'},
versions: [1, 2],
}),
'{"name":"projects/p/secrets/s","labels":{"env":"prod"},"versions":[1,2]}',
);
assert.strictEqual(resolveResponsePayload('raw body'), 'raw body');
// Empty responses (e.g. google.protobuf.Empty) still have a payload.
assert.strictEqual(resolveResponsePayload({}), '{}');
});

it('prefers the raw response provided for paginated and long-running calls', () => {
const resources = [{name: 'a'}, {name: 'b'}];
const rawResponse = {items: resources, nextPageToken: 'token'};
assert.strictEqual(
resolveResponsePayload(resources, rawResponse),
'{"items":[{"name":"a"},{"name":"b"}],"nextPageToken":"token"}',
);
// A null raw response falls back to the response.
assert.strictEqual(
resolveResponsePayload({name: 'a'}, null),
'{"name":"a"}',
);
});

it('returns undefined when there is no response', () => {
assert.strictEqual(resolveResponsePayload(undefined), undefined);
assert.strictEqual(resolveResponsePayload(null), undefined);
});

it('serializes protobuf messages through their JSON representation', () => {
const message = new GoogleErrorDecoder().root
.lookupType('google.rpc.ErrorInfo')
.fromObject(SERVICE_DISABLED_ERROR_INFO);
assert.strictEqual(
resolveResponsePayload(message),
'{"reason":"SERVICE_DISABLED","domain":"googleapis.com",' +
'"metadata":{"service":"translate.googleapis.com","consumer":"projects/123"}}',
);
});

it('encodes binary payloads as base64', () => {
assert.strictEqual(
resolveResponsePayload(Buffer.from('hello')),
'aGVsbG8=',
);
assert.strictEqual(
resolveResponsePayload(new Uint8Array([1, 2, 3]).buffer),
'AQID',
);
// Only the viewed bytes are encoded, not the rest of the underlying ArrayBuffer.
assert.strictEqual(
resolveResponsePayload(new Uint8Array([0, 1, 2, 3, 4]).subarray(1, 4)),
'AQID',
);
// Typed arrays from another realm are recognized too.
assert.strictEqual(
resolveResponsePayload(vm.runInNewContext('new Uint8Array([1, 2, 3])')),
'AQID',
);
});

it('encodes binary payloads as base64 when Buffer is not defined', () => {
const originalBuffer = (globalThis as Record<string, unknown>).Buffer;
try {
delete (globalThis as Record<string, unknown>).Buffer;
assert.strictEqual(
resolveResponsePayload(
new Uint8Array([0, 1, 2, 3, 4]).subarray(1, 4),
),
'AQID',
);
assert.strictEqual(
resolveResponsePayload(new Uint8Array([255])),
'/w==',
);
} finally {
(globalThis as Record<string, unknown>).Buffer = originalBuffer;
}
});
});

describe('isPreConnectionFailure', () => {
Expand Down
Loading