@@ -1146,6 +1146,162 @@ describe('GeminiChat', async () => {
11461146 }
11471147 } ) ;
11481148
1149+ it ( 'should retry immediately when skipDelay is called during rate-limit wait' , async ( ) => {
1150+ vi . useFakeTimers ( ) ;
1151+
1152+ try {
1153+ const tpmError = new StreamContentError (
1154+ '{"error":{"code":"429","message":"Throttling: TPM(1/1)"}}' ,
1155+ ) ;
1156+ const successStream = ( async function * ( ) {
1157+ yield {
1158+ candidates : [
1159+ {
1160+ content : { parts : [ { text : 'Success after skip' } ] } ,
1161+ finishReason : 'STOP' ,
1162+ } ,
1163+ ] ,
1164+ } as unknown as GenerateContentResponse ;
1165+ } ) ( ) ;
1166+
1167+ vi . mocked ( mockContentGenerator . generateContentStream )
1168+ . mockResolvedValueOnce (
1169+ ( async function * ( ) {
1170+ throw tpmError ;
1171+
1172+ yield { } as GenerateContentResponse ;
1173+ } ) ( ) ,
1174+ )
1175+ . mockResolvedValueOnce ( successStream ) ;
1176+
1177+ const stream = await chat . sendMessageStream (
1178+ 'test-model' ,
1179+ { message : 'test' } ,
1180+ 'prompt-id-skip-delay' ,
1181+ ) ;
1182+
1183+ const iterator = stream [ Symbol . asyncIterator ] ( ) ;
1184+ // First event: RETRY with retryInfo containing skipDelay
1185+ const first = await iterator . next ( ) ;
1186+ expect ( first . value . type ) . toBe ( StreamEventType . RETRY ) ;
1187+ const skipDelay = first . value . retryInfo ! . skipDelay ! ;
1188+
1189+ // Resume generator — it's now awaiting the 60s delay.
1190+ // Call skipDelay() to resolve it immediately instead of advancing timers.
1191+ const secondPromise = iterator . next ( ) ;
1192+ skipDelay ( ) ;
1193+ const second = await secondPromise ;
1194+
1195+ // The generator should have continued to the next attempt immediately
1196+ expect ( second . done ) . toBe ( false ) ;
1197+ expect ( second . value . type ) . toBe ( StreamEventType . RETRY ) ; // retry-start marker
1198+
1199+ // Consume remaining events
1200+ const events : StreamEvent [ ] = [ first . value , second . value ] ;
1201+ for ( ; ; ) {
1202+ const next = await iterator . next ( ) ;
1203+ if ( next . done ) break ;
1204+ events . push ( next . value ) ;
1205+ }
1206+
1207+ expect (
1208+ mockContentGenerator . generateContentStream ,
1209+ ) . toHaveBeenCalledTimes ( 2 ) ;
1210+ expect (
1211+ events . some (
1212+ ( e ) =>
1213+ e . type === StreamEventType . CHUNK &&
1214+ e . value . candidates ?. [ 0 ] ?. content ?. parts ?. [ 0 ] ?. text ===
1215+ 'Success after skip' ,
1216+ ) ,
1217+ ) . toBe ( true ) ;
1218+ } finally {
1219+ vi . useRealTimers ( ) ;
1220+ }
1221+ } ) ;
1222+
1223+ it ( 'should exit retry loop when aborted during rate-limit delay' , async ( ) => {
1224+ vi . useFakeTimers ( ) ;
1225+
1226+ try {
1227+ const tpmError = new StreamContentError (
1228+ '{"error":{"code":"429","message":"Throttling: TPM(1/1)"}}' ,
1229+ ) ;
1230+ async function * failingStreamGenerator ( ) {
1231+ throw tpmError ;
1232+
1233+ yield { } as GenerateContentResponse ;
1234+ }
1235+
1236+ const abortController = new AbortController ( ) ;
1237+
1238+ vi . mocked ( mockContentGenerator . generateContentStream )
1239+ . mockResolvedValueOnce ( failingStreamGenerator ( ) )
1240+ // Should never be called — abort should prevent the second attempt
1241+ . mockResolvedValueOnce ( failingStreamGenerator ( ) ) ;
1242+
1243+ const stream = await chat . sendMessageStream (
1244+ 'test-model' ,
1245+ { message : 'test' , config : { abortSignal : abortController . signal } } ,
1246+ 'prompt-id-abort-delay' ,
1247+ ) ;
1248+
1249+ const iterator = stream [ Symbol . asyncIterator ] ( ) ;
1250+ // First event: RETRY with retryInfo
1251+ const first = await iterator . next ( ) ;
1252+ expect ( first . value . type ) . toBe ( StreamEventType . RETRY ) ;
1253+
1254+ // Abort while the generator is awaiting the 60s delay
1255+ const nextPromise = iterator . next ( ) ;
1256+ abortController . abort ( ) ;
1257+
1258+ // The generator should throw the abort error
1259+ await expect ( nextPromise ) . rejects . toThrow ( ) ;
1260+
1261+ // Only one API call should have been made (no retry after abort)
1262+ expect (
1263+ mockContentGenerator . generateContentStream ,
1264+ ) . toHaveBeenCalledTimes ( 1 ) ;
1265+
1266+ // Verify the next sendMessageStream is not blocked by the old delay.
1267+ // If sendPromise were still pending, this would hang until the 60s
1268+ // timer fires — which never happens under fake timers, causing a timeout.
1269+ const nextStream = ( async function * ( ) {
1270+ yield {
1271+ candidates : [
1272+ {
1273+ content : { parts : [ { text : 'Next request OK' } ] } ,
1274+ finishReason : 'STOP' ,
1275+ } ,
1276+ ] ,
1277+ } as unknown as GenerateContentResponse ;
1278+ } ) ( ) ;
1279+ vi . mocked ( mockContentGenerator . generateContentStream )
1280+ . mockReset ( )
1281+ . mockResolvedValueOnce ( nextStream ) ;
1282+
1283+ const stream2 = await chat . sendMessageStream (
1284+ 'test-model' ,
1285+ { message : 'follow-up' } ,
1286+ 'prompt-id-after-abort' ,
1287+ ) ;
1288+ const events : StreamEvent [ ] = [ ] ;
1289+ for await ( const e of stream2 ) {
1290+ events . push ( e ) ;
1291+ }
1292+ expect (
1293+ events . some (
1294+ ( e ) =>
1295+ e . type === StreamEventType . CHUNK &&
1296+ e . value . candidates ?. [ 0 ] ?. content ?. parts ?. [ 0 ] ?. text ===
1297+ 'Next request OK' ,
1298+ ) ,
1299+ ) . toBe ( true ) ;
1300+ } finally {
1301+ vi . useRealTimers ( ) ;
1302+ }
1303+ } ) ;
1304+
11491305 it ( 'should retry on GLM rate limit StreamContentError with backoff delay' , async ( ) => {
11501306 vi . useFakeTimers ( ) ;
11511307
0 commit comments