From d2793e83e0234767abe19acbc571ae9843ba689c Mon Sep 17 00:00:00 2001 From: George Fu Date: Wed, 29 Oct 2025 15:06:49 -0400 Subject: [PATCH] fix(util-waiter): fix for circular refs in waiter debug logs --- .changeset/serious-chairs-accept.md | 5 ++ .../util-waiter/src/circularReplacer.spec.ts | 49 +++++++++++++++++++ packages/util-waiter/src/circularReplacer.ts | 17 +++++++ packages/util-waiter/src/poller.ts | 5 +- packages/util-waiter/src/waiter.ts | 26 ++++++---- 5 files changed, 91 insertions(+), 11 deletions(-) create mode 100644 .changeset/serious-chairs-accept.md create mode 100644 packages/util-waiter/src/circularReplacer.spec.ts create mode 100644 packages/util-waiter/src/circularReplacer.ts diff --git a/.changeset/serious-chairs-accept.md b/.changeset/serious-chairs-accept.md new file mode 100644 index 00000000000..d03a8b64003 --- /dev/null +++ b/.changeset/serious-chairs-accept.md @@ -0,0 +1,5 @@ +--- +"@smithy/util-waiter": patch +--- + +handle circular refs in debug messages diff --git a/packages/util-waiter/src/circularReplacer.spec.ts b/packages/util-waiter/src/circularReplacer.spec.ts new file mode 100644 index 00000000000..98dfc1c70c7 --- /dev/null +++ b/packages/util-waiter/src/circularReplacer.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; + +import { getCircularReplacer } from "./circularReplacer"; + +describe("getCircularReplacer", () => { + it("should handle nested circular references", () => { + const x = { + a: 1, + b: 2, + c: { + d: { + e: -1, + f: 3, + g: { + h: -1, + }, + }, + }, + } as any; + + x.c.d.e = x; + x.c.d.g.h = x; + + expect( + JSON.parse( + JSON.stringify( + { + x, + }, + getCircularReplacer() + ) + ) + ).toEqual({ + x: { + a: 1, + b: 2, + c: { + d: { + e: "[Circular]", + f: 3, + g: { + h: "[Circular]", + }, + }, + }, + }, + }); + }); +}); diff --git a/packages/util-waiter/src/circularReplacer.ts b/packages/util-waiter/src/circularReplacer.ts new file mode 100644 index 00000000000..cb76e61ecbc --- /dev/null +++ b/packages/util-waiter/src/circularReplacer.ts @@ -0,0 +1,17 @@ +/** + * Helper for JSON stringification debug logging. + * + * @internal + */ +export const getCircularReplacer = () => { + const seen = new WeakSet(); + return (key: any, value: any) => { + if (typeof value === "object" && value !== null) { + if (seen.has(value)) { + return "[Circular]"; + } + seen.add(value); + } + return value; + }; +}; diff --git a/packages/util-waiter/src/poller.ts b/packages/util-waiter/src/poller.ts index 3260ea79577..08555119f02 100644 --- a/packages/util-waiter/src/poller.ts +++ b/packages/util-waiter/src/poller.ts @@ -1,3 +1,4 @@ +import { getCircularReplacer } from "./circularReplacer"; import { sleep } from "./utils/sleep"; import type { WaiterOptions, WaiterResult } from "./waiter"; import { WaiterState } from "./waiter"; @@ -21,7 +22,7 @@ const randomInRange = (min: number, max: number) => min + Math.random() * (max - * @param params - options passed to the waiter. * @param client - AWS SDK Client * @param input - client input - * @param stateChecker - function that checks the acceptor states on each poll. + * @param acceptorChecks - function that checks the acceptor states on each poll. */ export const runPolling = async ( { minDelay, maxDelay, maxWaitTime, abortController, client, abortSignal }: WaiterOptions, @@ -96,5 +97,5 @@ const createMessageFromResponse = (reason: any): string => { return `${reason.$metadata.httpStatusCode}: OK`; } // is an unknown object. - return String(reason?.message ?? JSON.stringify(reason) ?? "Unknown"); + return String(reason?.message ?? JSON.stringify(reason, getCircularReplacer()) ?? "Unknown"); }; diff --git a/packages/util-waiter/src/waiter.ts b/packages/util-waiter/src/waiter.ts index 734dffca9c8..92f63b4b84c 100644 --- a/packages/util-waiter/src/waiter.ts +++ b/packages/util-waiter/src/waiter.ts @@ -1,5 +1,7 @@ import type { WaiterConfiguration as WaiterConfiguration__ } from "@smithy/types"; +import { getCircularReplacer } from "./circularReplacer"; + /** * @internal */ @@ -57,24 +59,30 @@ export type WaiterResult = { export const checkExceptions = (result: WaiterResult): WaiterResult => { if (result.state === WaiterState.ABORTED) { const abortError = new Error( - `${JSON.stringify({ - ...result, - reason: "Request was aborted", - })}` + `${JSON.stringify( + { + ...result, + reason: "Request was aborted", + }, + getCircularReplacer() + )}` ); abortError.name = "AbortError"; throw abortError; } else if (result.state === WaiterState.TIMEOUT) { const timeoutError = new Error( - `${JSON.stringify({ - ...result, - reason: "Waiter has timed out", - })}` + `${JSON.stringify( + { + ...result, + reason: "Waiter has timed out", + }, + getCircularReplacer() + )}` ); timeoutError.name = "TimeoutError"; throw timeoutError; } else if (result.state !== WaiterState.SUCCESS) { - throw new Error(`${JSON.stringify(result)}`); + throw new Error(`${JSON.stringify(result, getCircularReplacer())}`); } return result; };