From 243f64be59b77d73f5b8512c8f3b4496a8a09f08 Mon Sep 17 00:00:00 2001 From: David Brailovsky Date: Tue, 21 Jul 2026 15:34:31 +0000 Subject: [PATCH] report aborted retry attempts as unsuccessful --- packages/ai/src/utils/retry.ts | 17 ++++++++++++----- packages/ai/test/retry.test.ts | 15 +++++++++++++++ 2 files changed, 27 insertions(+), 5 deletions(-) diff --git a/packages/ai/src/utils/retry.ts b/packages/ai/src/utils/retry.ts index 441b40d7..85ab4636 100644 --- a/packages/ai/src/utils/retry.ts +++ b/packages/ai/src/utils/retry.ts @@ -110,7 +110,7 @@ export interface RetryCallbacks { ) => void | Promise; /** Emitted after the backoff sleep, immediately before the retried call starts. */ onRetryAttemptStart?: () => void | Promise; - /** Emitted once when the loop ends: success if a later call returned a non-error message. */ + /** Emitted once when the loop ends: success if a later call completed normally. */ onRetryFinished?: (success: boolean, attempt: number, finalError?: string) => void | Promise; } @@ -142,9 +142,10 @@ function sleep(ms: number, signal?: AbortSignal): Promise { * Run a single assistant-producing call with bounded retry on transient errors. * * Behavior: - * - A non-error response (success or aborted) is returned immediately; aborts are - * never retried. Aborts during the backoff sleep are normalized to an aborted - * `AssistantMessage` too, so callers do not need to care when cancellation happened. + * - A successful response is returned immediately. Aborts are terminal and never + * retried, but reported as unsuccessful if they happen after a retry was scheduled. + * Aborts during the backoff sleep are normalized to an aborted `AssistantMessage` + * too, so callers do not need to care when cancellation happened. * - A non-retryable error (per {@link isRetryableAssistantError}, including quota/ * billing exhaustion) is returned immediately so deterministic errors fail fast. * - Otherwise retries up to `maxRetries` times with exponential backoff, emitting @@ -168,7 +169,13 @@ export async function retryAssistantCall( for (;;) { const response = await produce(); - // Success or abort: never retry an aborted message; non-error returns as-is. + // Abort: terminal but not successful. Never retry an aborted message. + if (response.stopReason === "aborted") { + if (lastRetry) await callbacks?.onRetryFinished?.(false, lastRetry.attempt); + return response; + } + + // Success: non-error, non-abort responses return as-is. if (response.stopReason !== "error") { if (lastRetry) await callbacks?.onRetryFinished?.(true, lastRetry.attempt); return response; diff --git a/packages/ai/test/retry.test.ts b/packages/ai/test/retry.test.ts index eaae872e..12ced28c 100644 --- a/packages/ai/test/retry.test.ts +++ b/packages/ai/test/retry.test.ts @@ -126,6 +126,21 @@ describe("retryAssistantCall", () => { expect(onRetryFinished).toHaveBeenCalledWith(true, 2); }); + it("reports an aborted retried call as unsuccessful", async () => { + let n = 0; + const produce = vi.fn(async () => { + n++; + return n === 1 + ? fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" }) + : fauxAssistantMessage("", { stopReason: "aborted" }); + }); + const onRetryFinished = vi.fn(); + const res = await retryAssistantCall(produce, enabled, undefined, { onRetryFinished }); + expect(res.stopReason).toBe("aborted"); + expect(produce).toHaveBeenCalledTimes(2); + expect(onRetryFinished).toHaveBeenCalledWith(false, 1); + }); + it("does not retry when policy is disabled", async () => { const produce = vi.fn(async () => fauxAssistantMessage("", { stopReason: "error", errorMessage: "terminated" })); const onRetryScheduled = vi.fn();