Skip to content

Commit 8712670

Browse files
committed
fix: make all retry delays abort-aware to unblock sendPromise
Rename skippableDelay to delay and thread AbortSignal into all three retry delay sites (rate-limit, invalid-stream, content-error). When the request's AbortSignal fires during any countdown, the delay promise rejects, the generator exits via its finally block, and sendPromise is released so the next sendMessageStream call can proceed immediately. Previously, cancelling during a rate-limit countdown (up to 60s) would leave the generator blocked on the delay, preventing any subsequent request from starting due to sendPromise serialization.
1 parent 73ab82a commit 8712670

2 files changed

Lines changed: 117 additions & 11 deletions

File tree

packages/core/src/core/geminiChat.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1172,6 +1172,88 @@ describe('GeminiChat', async () => {
11721172
}
11731173
});
11741174

1175+
it('should exit retry loop when aborted during rate-limit delay', async () => {
1176+
vi.useFakeTimers();
1177+
1178+
try {
1179+
const tpmError = new StreamContentError(
1180+
'{"error":{"code":"429","message":"Throttling: TPM(1/1)"}}',
1181+
);
1182+
async function* failingStreamGenerator() {
1183+
throw tpmError;
1184+
1185+
yield {} as GenerateContentResponse;
1186+
}
1187+
1188+
const abortController = new AbortController();
1189+
1190+
vi.mocked(mockContentGenerator.generateContentStream)
1191+
.mockResolvedValueOnce(failingStreamGenerator())
1192+
// Should never be called — abort should prevent the second attempt
1193+
.mockResolvedValueOnce(failingStreamGenerator());
1194+
1195+
const stream = await chat.sendMessageStream(
1196+
'test-model',
1197+
{ message: 'test', config: { abortSignal: abortController.signal } },
1198+
'prompt-id-abort-delay',
1199+
);
1200+
1201+
const iterator = stream[Symbol.asyncIterator]();
1202+
// First event: RETRY with retryInfo
1203+
const first = await iterator.next();
1204+
expect(first.value.type).toBe(StreamEventType.RETRY);
1205+
1206+
// Abort while the generator is awaiting the 60s delay
1207+
const nextPromise = iterator.next();
1208+
abortController.abort();
1209+
1210+
// The generator should throw the abort error
1211+
await expect(nextPromise).rejects.toThrow();
1212+
1213+
// Only one API call should have been made (no retry after abort)
1214+
expect(
1215+
mockContentGenerator.generateContentStream,
1216+
).toHaveBeenCalledTimes(1);
1217+
1218+
// Verify the next sendMessageStream is not blocked by the old delay.
1219+
// If sendPromise were still pending, this would hang until the 60s
1220+
// timer fires — which never happens under fake timers, causing a timeout.
1221+
const nextStream = (async function* () {
1222+
yield {
1223+
candidates: [
1224+
{
1225+
content: { parts: [{ text: 'Next request OK' }] },
1226+
finishReason: 'STOP',
1227+
},
1228+
],
1229+
} as unknown as GenerateContentResponse;
1230+
})();
1231+
vi.mocked(mockContentGenerator.generateContentStream)
1232+
.mockReset()
1233+
.mockResolvedValueOnce(nextStream);
1234+
1235+
const stream2 = await chat.sendMessageStream(
1236+
'test-model',
1237+
{ message: 'follow-up' },
1238+
'prompt-id-after-abort',
1239+
);
1240+
const events: StreamEvent[] = [];
1241+
for await (const e of stream2) {
1242+
events.push(e);
1243+
}
1244+
expect(
1245+
events.some(
1246+
(e) =>
1247+
e.type === StreamEventType.CHUNK &&
1248+
e.value.candidates?.[0]?.content?.parts?.[0]?.text ===
1249+
'Next request OK',
1250+
),
1251+
).toBe(true);
1252+
} finally {
1253+
vi.useRealTimers();
1254+
}
1255+
});
1256+
11751257
it('should retry on GLM rate limit StreamContentError with backoff delay', async () => {
11761258
vi.useFakeTimers();
11771259

packages/core/src/core/geminiChat.ts

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -87,17 +87,41 @@ const RATE_LIMIT_RETRY_OPTIONS = {
8787
/**
8888
* Creates a promise that resolves after the specified delay, but can be
8989
* resolved early by calling the returned `skip` function.
90+
*
91+
* If an `AbortSignal` is provided and it fires before the delay completes,
92+
* the promise rejects so the caller's `await` throws and normal error
93+
* propagation takes over (e.g. the retry loop breaks and the generator exits).
9094
*/
91-
function skippableDelay(delayMs: number): {
95+
function delay(
96+
delayMs: number,
97+
signal?: AbortSignal,
98+
): {
9299
promise: Promise<void>;
93100
skip: () => void;
94101
} {
95102
let resolveRef: () => void;
96103
let timeoutId: ReturnType<typeof setTimeout>;
97-
const promise = new Promise<void>((resolve) => {
104+
105+
const promise = new Promise<void>((resolve, reject) => {
98106
resolveRef = resolve;
107+
108+
if (signal?.aborted) {
109+
reject(signal.reason);
110+
return;
111+
}
112+
99113
timeoutId = setTimeout(resolve, delayMs);
114+
115+
signal?.addEventListener(
116+
'abort',
117+
() => {
118+
clearTimeout(timeoutId);
119+
reject(signal.reason);
120+
},
121+
{ once: true },
122+
);
100123
});
124+
101125
return {
102126
promise,
103127
skip: () => {
@@ -371,7 +395,10 @@ export class GeminiChat {
371395
`Rate limit throttling detected (retry ${rateLimitRetryCount}/${maxRateLimitRetries}). ` +
372396
`Waiting ${delayMs / 1000}s before retrying...`,
373397
);
374-
const { promise: delayPromise, skip } = skippableDelay(delayMs);
398+
const { promise: delayPromise, skip } = delay(
399+
delayMs,
400+
params.config?.abortSignal,
401+
);
375402
yield {
376403
type: StreamEventType.RETRY,
377404
retryInfo: {
@@ -417,7 +444,7 @@ export class GeminiChat {
417444
yield { type: StreamEventType.RETRY };
418445
// Don't count transient retries against content retry limit.
419446
attempt--;
420-
await new Promise((res) => setTimeout(res, delayMs));
447+
await delay(delayMs, params.config?.abortSignal).promise;
421448
continue;
422449
}
423450
// Transient budget exhausted — stop immediately.
@@ -438,13 +465,10 @@ export class GeminiChat {
438465
model,
439466
),
440467
);
441-
await new Promise((res) =>
442-
setTimeout(
443-
res,
444-
INVALID_CONTENT_RETRY_OPTIONS.initialDelayMs *
445-
(attempt + 1),
446-
),
447-
);
468+
await delay(
469+
INVALID_CONTENT_RETRY_OPTIONS.initialDelayMs * (attempt + 1),
470+
params.config?.abortSignal,
471+
).promise;
448472
continue;
449473
}
450474
}

0 commit comments

Comments
 (0)