@@ -565,6 +565,105 @@ describe("OpenAIAuthPlugin rate-limit retry", () => {
565565 ) ;
566566 } ) ;
567567
568+ it ( "keeps the total request cap when empty-response retries and server-error rotation combine" , async ( ) => {
569+ const logger = await import ( "../lib/logger.js" ) ;
570+ const logWarnSpy = vi . spyOn ( logger , "logWarn" ) . mockImplementation ( ( ) => { } ) ;
571+
572+ const accounts = Array . from ( { length : 6 } , ( _ , index ) =>
573+ createMockAccount ( {
574+ index,
575+ accountId : `account-${ index + 1 } ` ,
576+ email : `user${ index + 1 } @example.com` ,
577+ refreshToken : `refresh-token-${ index + 1 } ` ,
578+ access : `access-token-account-${ index + 1 } ` ,
579+ } ) ,
580+ ) ;
581+ accountManagerState . accounts = accounts ;
582+ accountManagerState . accountSelections = [ ...accounts ] ;
583+
584+ const serverErrorResponse = ( ) =>
585+ new Response (
586+ JSON . stringify ( {
587+ error : { code : "server_error" , message : "temporary outage" } ,
588+ } ) ,
589+ {
590+ status : 500 ,
591+ headers : { "content-type" : "application/json" } ,
592+ } ,
593+ ) ;
594+
595+ const fetchMock = vi
596+ . fn ( )
597+ . mockResolvedValueOnce (
598+ new Response ( "{}" , {
599+ status : 200 ,
600+ headers : { "content-type" : "application/json" } ,
601+ } ) ,
602+ )
603+ . mockResolvedValueOnce ( serverErrorResponse ( ) )
604+ . mockResolvedValueOnce ( serverErrorResponse ( ) )
605+ . mockResolvedValueOnce ( serverErrorResponse ( ) )
606+ . mockResolvedValueOnce ( serverErrorResponse ( ) )
607+ . mockResolvedValueOnce ( serverErrorResponse ( ) ) ;
608+ globalThis . fetch = fetchMock as any ;
609+
610+ const { OpenAIAuthPlugin } = await import ( "../index.js" ) ;
611+ const client = {
612+ tui : { showToast : vi . fn ( ) } ,
613+ auth : { set : vi . fn ( ) } ,
614+ } as any ;
615+ const plugin = await OpenAIAuthPlugin ( { client } ) ;
616+
617+ const getAuth = async ( ) => ( {
618+ type : "oauth" as const ,
619+ access : "a" ,
620+ refresh : "r" ,
621+ expires : Date . now ( ) + 60_000 ,
622+ multiAccount : true ,
623+ } ) ;
624+
625+ const sdk = ( await plugin . auth . loader ( getAuth , { options : { } , models : { } } ) ) as any ;
626+ const fetchPromise = sdk . fetch ( "https://example.com" , { } ) ;
627+
628+ await vi . advanceTimersByTimeAsync ( 1500 ) ;
629+
630+ const response = await fetchPromise ;
631+ const payload = await response . json ( ) ;
632+
633+ expect ( fetchMock ) . toHaveBeenCalledTimes ( 6 ) ;
634+ expect (
635+ fetchMock . mock . calls . map (
636+ ( call ) => ( call [ 1 ] ?. headers as Headers ) . get ( "x-account-id" ) ,
637+ ) ,
638+ ) . toEqual ( [
639+ "account-1" ,
640+ "account-1" ,
641+ "account-2" ,
642+ "account-3" ,
643+ "account-4" ,
644+ "account-5" ,
645+ ] ) ;
646+ expect ( response . status ) . toBe ( 503 ) ;
647+ expect ( payload ) . toEqual ( {
648+ error : {
649+ message :
650+ "Request attempt budget exhausted after 6 outbound request(s). Try again after the current retries settle." ,
651+ } ,
652+ } ) ;
653+ expect ( logWarnSpy ) . toHaveBeenCalledWith (
654+ "Empty response received (attempt 1/2). Retrying..." ,
655+ ) ;
656+ expect ( logWarnSpy ) . toHaveBeenCalledWith (
657+ "Request attempt budget exhausted." ,
658+ expect . objectContaining ( {
659+ reason : "primary" ,
660+ accountIndex : 5 ,
661+ budget : 6 ,
662+ consumed : 6 ,
663+ } ) ,
664+ ) ;
665+ } ) ;
666+
568667 it ( "rebuilds request headers after rotating to the next workspace" , async ( ) => {
569668 const account = createMockAccount ( {
570669 workspaces : [
0 commit comments