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
52 changes: 47 additions & 5 deletions core/packages/gax/src/gax.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ export class CallSettings {
}
let timeout = this.timeout;
let retry = this.retry;
let retryCloned = false;
let autoPaginate = this.autoPaginate;
let maxResults = this.maxResults;
let otherArgs = this.otherArgs;
Expand All @@ -277,14 +278,37 @@ export class CallSettings {
// If a method-specific timeout is set in the service config, and the retry codes for that
// method are non-null, then that timeout value will be used to
// override backoff settings.
if (retry?.retryCodes) {
retry!.backoffSettings.initialRpcTimeoutMillis = timeout;
retry!.backoffSettings.maxRpcTimeoutMillis = timeout;
retry!.backoffSettings.totalTimeoutMillis = timeout;
if (
retry?.retryCodes &&
!('retry' in options && (!options.retry || options.retry.backoffSettings))
) {
const backoff = retry.backoffSettings;
if (
!backoff ||
backoff.initialRpcTimeoutMillis !== timeout ||
backoff.maxRpcTimeoutMillis !== timeout ||
backoff.totalTimeoutMillis !== timeout
) {
retry = cloneRetryOptions(retry);
retryCloned = true;
retry!.backoffSettings.initialRpcTimeoutMillis = timeout;
retry!.backoffSettings.maxRpcTimeoutMillis = timeout;
retry!.backoffSettings.totalTimeoutMillis = timeout;
}
}

if ('retry' in options) {
retry = mergeRetryOptions(retry || ({} as RetryOptions), options.retry!);
const mergedRetry = mergeRetryOptions(
retry || ({} as RetryOptions),
options.retry!,
);
if (
!mergedRetry ||
mergedRetry.backoffSettings !== retry?.backoffSettings
) {
retryCloned = false;
}
retry = mergedRetry;
}

if ('autoPaginate' in options && !options.autoPaginate) {
Expand All @@ -310,6 +334,10 @@ export class CallSettings {
}

if ('maxRetries' in options && options.maxRetries !== undefined) {
if (!retryCloned) {
retry = cloneRetryOptions(retry);
retryCloned = true;
}
retry!.backoffSettings!.maxRetries = options.maxRetries;
delete retry!.backoffSettings!.totalTimeoutMillis;
}
Expand Down Expand Up @@ -681,6 +709,20 @@ function constructRetry(
return createRetryOptions(codes!, backoffSettings!);
}

function cloneRetryOptions(
retry?: RetryOptions | null,
): RetryOptions | null | undefined {
if (!retry) {
return retry;
}
return createRetryOptions(
retry.retryCodes ? [...retry.retryCodes] : retry.retryCodes,
retry.backoffSettings ? {...retry.backoffSettings} : retry.backoffSettings,
retry.shouldRetryFn,
retry.getResumptionRequestFn,
);
}

/**
* Helper for {@link constructSettings}
*
Expand Down
23 changes: 17 additions & 6 deletions core/packages/gax/src/normalCalls/retries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,9 +76,10 @@
let timeoutId: ReturnType<typeof setTimeout> | null;
let cancelled = false;
let now = new Date();
let deadline: number;
if (retry.backoffSettings.totalTimeoutMillis) {
deadline = now.getTime() + retry.backoffSettings.totalTimeoutMillis;
let deadline: number | undefined;
const totalTimeoutMillis = retry.backoffSettings.totalTimeoutMillis;
if (totalTimeoutMillis !== undefined && totalTimeoutMillis !== null) {
deadline = now.getTime() + totalTimeoutMillis;
}
let retries = 0;
const maxRetries = retry.backoffSettings.maxRetries!;
Expand Down Expand Up @@ -122,7 +123,11 @@
errorsEncountered.push(err);
}
timeoutId = null;
if (deadline && now.getTime() >= deadline) {
if (
deadline !== undefined &&
(retries > 0 || totalTimeoutMillis! > 0) &&
now.getTime() >= deadline
) {
const error = new GoogleError(
`Total timeout of API ${apiName} exceeded ${
retry.backoffSettings.totalTimeoutMillis
Expand Down Expand Up @@ -175,27 +180,33 @@
'not classified as transient';
callback(err);
} else {
now = new Date();
if (deadline !== undefined && now.getTime() >= deadline) {
repeat(lastError);
return;
}
const toSleep = Math.random() * delay;
timeoutId = setTimeout(() => {
now = new Date();
delay = Math.min(delay * delayMult, maxDelay);
const timeoutCal =
timeout && timeoutMult ? timeout * timeoutMult : 0;
const rpcTimeout = maxTimeout ? maxTimeout : 0;
const newDeadline = deadline ? deadline - now.getTime() : Infinity;
const newDeadline =
deadline !== undefined ? deadline - now.getTime() : Infinity;
timeout = Math.min(timeoutCal, rpcTimeout, newDeadline);
repeat(lastError);
}, toSleep);
}
});
if (canceller instanceof Promise) {
canceller.catch(err => {

Check warning on line 203 in core/packages/gax/src/normalCalls/retries.ts

View workflow job for this annotation

GitHub Actions / lint

Avoid using promises inside of callbacks
callback(new GoogleError(err));

Check warning on line 204 in core/packages/gax/src/normalCalls/retries.ts

View workflow job for this annotation

GitHub Actions / lint

Avoid calling back inside of a promise
});
}
}

if (maxRetries && deadline!) {
if (maxRetries && deadline !== undefined) {
const error = new GoogleError(
'Cannot set both totalTimeoutMillis and maxRetries ' +
'in backoffSettings.',
Expand Down
140 changes: 52 additions & 88 deletions core/packages/gax/src/observability/errorUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -798,16 +798,6 @@ export function isServerSideError(
return resolveServerErrorCode(e, rpcType) !== undefined;
}

/**
* Checks whether a value is a Node.js Buffer instance.
*
* @param {unknown} val - The value to check.
* @returns {boolean} True if `val` is a Buffer.
*/
function isBuffer(val: unknown): val is Buffer {
return typeof Buffer !== 'undefined' && Buffer.isBuffer(val);
}

/**
* Safely converts a value to a JSON string without throwing exceptions on
* circular references, BigInt values, or non-serializable properties.
Expand Down Expand Up @@ -856,96 +846,41 @@ export function safeJsonStringify(value: unknown): string | undefined {
}

/**
* Extracts and formats server-side error details (message, status details, and metadata)
* attached by GFE/backend on server-side errors. Note that only `message` is used on
* low level network `exception` span events (`exception.stacktrace` is not recorded on spans).
* Extracts the server-side error details message attached by GFE/backend on
* server-side errors, checking the error and its `.cause` chain and falling
* back to `e.message` when no non-empty `details` string is present.
*
* @param {Error} e - The server Error instance.
* @returns {{message: string; stacktrace?: string}} The resolved server message and formatted details string.
* @returns {{message: string}} The resolved server error message.
*/
export function resolveServerExceptionDetails(e: Error): {
message: string;
stacktrace?: string;
} {
ensureGrpcStatusDetailsParsed(e);

const errObj = e as {
details?: unknown;
statusDetails?: unknown;
metadata?: unknown;
cause?: unknown;
};

const causeObj =
errObj.cause && typeof errObj.cause === 'object'
? (errObj.cause as {
details?: unknown;
statusDetails?: unknown;
metadata?: unknown;
})
: undefined;
let current: unknown = e;
const seen = new Set<unknown>();
for (let depth = 0; depth < DEPTH_TO_CHECK; depth++) {
if (!current || typeof current !== 'object') {
break;
}
if (seen.has(current)) {
break;
}
seen.add(current);

// Prefer server error details over generic error message.
const serverDetails = errObj.details ?? causeObj?.details;
const message =
typeof serverDetails === 'string' && serverDetails.length > 0
? serverDetails
: e.message;

// Serialize status details if present.
const rawStatusDetails = errObj.statusDetails ?? causeObj?.statusDetails;
let statusDetailsStr: string | undefined;
if (rawStatusDetails !== undefined && rawStatusDetails !== null) {
statusDetailsStr =
typeof rawStatusDetails === 'string'
? rawStatusDetails
: safeJsonStringify(rawStatusDetails);
}
const errObj = current as {
details?: unknown;
cause?: unknown;
};

// Serialize backend metadata, encoding Buffer values as base64.
const rawMetadata = errObj.metadata ?? causeObj?.metadata;
let metadataStr: string | undefined;
if (rawMetadata && typeof rawMetadata === 'object') {
try {
let map: Record<string, unknown>;
if (typeof (rawMetadata as {getMap?: unknown}).getMap === 'function') {
map = (rawMetadata as {getMap: () => Record<string, unknown>}).getMap();
} else {
map = rawMetadata as Record<string, unknown>;
}
const cleanMap: Record<string, unknown> = {};
for (const [key, val] of Object.entries(map)) {
if (isBuffer(val)) {
cleanMap[key] = val.toString('base64');
} else if (Array.isArray(val)) {
cleanMap[key] = val.map(item =>
isBuffer(item) ? item.toString('base64') : item,
);
} else {
cleanMap[key] = val;
}
}
metadataStr = safeJsonStringify(cleanMap);
} catch {
metadataStr = safeJsonStringify(rawMetadata);
if (typeof errObj.details === 'string' && errObj.details.length > 0) {
return {message: errObj.details};
}
}

// Combine status details and metadata into a formatted stacktrace string.
const stacktraceParts: string[] = [];
if (statusDetailsStr) {
stacktraceParts.push(`status_details: ${statusDetailsStr}`);
current = errObj.cause;
}
if (metadataStr) {
stacktraceParts.push(`metadata: ${metadataStr}`);
}
const stacktrace =
stacktraceParts.length > 0 ? stacktraceParts.join('\n') : undefined;

return {
message,
stacktrace,
};
// Prefer server error details over generic error message.
return {message: e.message};
}

/**
Expand All @@ -967,6 +902,35 @@ export function resolveExceptionMessage(
: resolveErrorMessage(e);
}

/**
* Resolves the `exception.stacktrace` attribute as the natural JavaScript call stack
* (`stack`) from the error (or the first non-empty `stack` along its `.cause` chain).
* Raw server status details and metadata belong in L4 `response.payload`, not
* `exception.stacktrace`.
*
* @param {unknown} e - The error or other value thrown or reported for the call.
* @returns {string | undefined} The resolved `exception.stacktrace` string, if any.
*/
export function resolveExceptionStacktrace(e: unknown): string | undefined {
let current: unknown = e;
const seen = new Set<unknown>();
for (let depth = 0; depth < DEPTH_TO_CHECK; depth++) {
if (!current || typeof current !== 'object') {
break;
}
if (seen.has(current)) {
break;
}
seen.add(current);
const stack = (current as {stack?: unknown}).stack;
if (typeof stack === 'string' && stack.length > 0) {
return stack;
}
current = (current as {cause?: unknown}).cause;
}
return undefined;
}

/**
* Records an `exception` span event with `exception.type` (and `exception.message`
* when `includeMessage` is true, such as on low level network attempt spans).
Expand Down
Loading
Loading