@@ -4427,6 +4427,271 @@ describe("OpenAIOAuthPlugin runtime toast forwarding", () => {
44274427 ) ;
44284428 } ) ;
44294429
4430+ it ( "retries same account on short-cooldown 429 without marking rate-limited" , async ( ) => {
4431+ const { AccountManager } = await import ( "../lib/accounts.js" ) ;
4432+ const fetchHelpersModule = await import ( "../lib/request/fetch-helpers.js" ) ;
4433+ const rateLimitBackoffModule = await import ( "../lib/request/rate-limit-backoff.js" ) ;
4434+
4435+ const markRateLimitedWithReason = vi . fn ( ) ;
4436+ const manager = {
4437+ getAccountCount : ( ) => 1 ,
4438+ getCurrentOrNextForFamilyHybrid : ( ) => ( { index : 0 , accountId : "acc-1" , email : "alpha@example.com" , refreshToken : "refresh-1" } ) ,
4439+ getCurrentOrNextForFamily : ( ) => ( { index : 0 , accountId : "acc-1" , email : "alpha@example.com" , refreshToken : "refresh-1" } ) ,
4440+ getCurrentWorkspace : ( ) => null ,
4441+ getAccountByIndex : ( ) => null ,
4442+ getAccountsSnapshot : ( ) => [ ] ,
4443+ isAccountAvailableForFamily : ( ) => true ,
4444+ toAuthDetails : ( ) => ( { type : "oauth" as const , access : "access-token" , refresh : "refresh-1" , expires : Date . now ( ) + 60_000 } ) ,
4445+ hasRefreshToken : ( ) => true ,
4446+ saveToDiskDebounced : ( ) => { } ,
4447+ updateFromAuth : ( ) => { } ,
4448+ clearAuthFailures : ( ) => { } ,
4449+ incrementAuthFailures : ( ) => 1 ,
4450+ saveToDisk : async ( ) => { } ,
4451+ markAccountCoolingDown : ( ) => { } ,
4452+ markRateLimited : ( ) => { } ,
4453+ markRateLimitedWithReason,
4454+ consumeToken : ( ) => true ,
4455+ refundToken : ( ) => { } ,
4456+ syncCodexCliActiveSelectionForIndex : async ( ) => { } ,
4457+ markSwitched : ( ) => { } ,
4458+ removeAccount : ( ) => { } ,
4459+ recordFailure : ( ) => { } ,
4460+ recordSuccess : ( ) => { } ,
4461+ recordRateLimit : ( ) => { } ,
4462+ getMinWaitTimeForFamily : ( ) => 0 ,
4463+ shouldShowAccountToast : ( ) => false ,
4464+ markToastShown : ( ) => { } ,
4465+ setActiveIndex : ( ) => null ,
4466+ } ;
4467+ vi . spyOn ( AccountManager , "loadFromDisk" ) . mockResolvedValue ( manager as never ) ;
4468+ // Short cooldown: 1000ms < 5000ms threshold -> retries same account, does NOT rotate
4469+ vi . mocked ( fetchHelpersModule . handleErrorResponse ) . mockResolvedValueOnce ( {
4470+ response : new Response ( "rate limited" , { status : 429 } ) ,
4471+ rateLimit : { retryAfterMs : 1000 , code : "rate_limit_exceeded" } ,
4472+ errorBody : "rate limited" ,
4473+ } as never ) ;
4474+ vi . mocked ( rateLimitBackoffModule . getRateLimitBackoff ) . mockReturnValueOnce ( { attempt : 1 , delayMs : 500 } ) ;
4475+ globalThis . fetch = vi
4476+ . fn ( )
4477+ . mockResolvedValueOnce ( new Response ( "rate limited" , { status : 429 } ) )
4478+ . mockResolvedValueOnce ( new Response ( JSON . stringify ( { content : "ok" } ) , { status : 200 } ) ) ;
4479+
4480+ const mockClient = createMockClient ( ) ;
4481+ const { OpenAIOAuthPlugin } = await import ( "../index.js" ) ;
4482+ const plugin = await OpenAIOAuthPlugin ( { client : mockClient } as never ) as unknown as PluginType ;
4483+ const sdk = await plugin . auth . loader ( getOAuthAuth , { options : { } , models : { } } ) ;
4484+ const response = await sdk . fetch ! ( "https://api.openai.com/v1/chat/completions" , {
4485+ method : "POST" ,
4486+ body : JSON . stringify ( { model : "gpt-5.1" } ) ,
4487+ } ) ;
4488+
4489+ // Short cooldown -> retries same account -> gets 200 on second attempt
4490+ expect ( response . status ) . toBe ( 200 ) ;
4491+ // markRateLimitedWithReason is for long cooldowns; short retries skip it
4492+ expect ( markRateLimitedWithReason ) . not . toHaveBeenCalled ( ) ;
4493+ expect ( globalThis . fetch ) . toHaveBeenCalledTimes ( 2 ) ;
4494+ } ) ;
4495+
4496+ it ( "does not rotate on 404 with unrelated body (not a usage limit)" , async ( ) => {
4497+ const { AccountManager } = await import ( "../lib/accounts.js" ) ;
4498+ const fetchHelpersModule = await import ( "../lib/request/fetch-helpers.js" ) ;
4499+
4500+ const markRateLimitedWithReason = vi . fn ( ) ;
4501+ const manager = {
4502+ getAccountCount : ( ) => 1 ,
4503+ getCurrentOrNextForFamilyHybrid : ( ) => ( { index : 0 , accountId : "acc-1" , email : "alpha@example.com" , refreshToken : "refresh-1" } ) ,
4504+ getCurrentOrNextForFamily : ( ) => ( { index : 0 , accountId : "acc-1" , email : "alpha@example.com" , refreshToken : "refresh-1" } ) ,
4505+ getCurrentWorkspace : ( ) => null ,
4506+ getAccountByIndex : ( ) => null ,
4507+ getAccountsSnapshot : ( ) => [ ] ,
4508+ isAccountAvailableForFamily : ( ) => true ,
4509+ toAuthDetails : ( ) => ( { type : "oauth" as const , access : "access-token" , refresh : "refresh-1" , expires : Date . now ( ) + 60_000 } ) ,
4510+ hasRefreshToken : ( ) => true ,
4511+ saveToDiskDebounced : ( ) => { } ,
4512+ updateFromAuth : ( ) => { } ,
4513+ clearAuthFailures : ( ) => { } ,
4514+ incrementAuthFailures : ( ) => 1 ,
4515+ saveToDisk : async ( ) => { } ,
4516+ markAccountCoolingDown : ( ) => { } ,
4517+ markRateLimited : ( ) => { } ,
4518+ markRateLimitedWithReason,
4519+ consumeToken : ( ) => true ,
4520+ refundToken : ( ) => { } ,
4521+ syncCodexCliActiveSelectionForIndex : async ( ) => { } ,
4522+ markSwitched : ( ) => { } ,
4523+ removeAccount : ( ) => { } ,
4524+ recordFailure : ( ) => { } ,
4525+ recordSuccess : ( ) => { } ,
4526+ recordRateLimit : ( ) => { } ,
4527+ getMinWaitTimeForFamily : ( ) => 0 ,
4528+ shouldShowAccountToast : ( ) => false ,
4529+ markToastShown : ( ) => { } ,
4530+ setActiveIndex : ( ) => null ,
4531+ } ;
4532+ vi . spyOn ( AccountManager , "loadFromDisk" ) . mockResolvedValue ( manager as never ) ;
4533+ // 404 with unrelated body: handleErrorResponse returns 404 + no rateLimit
4534+ vi . mocked ( fetchHelpersModule . handleErrorResponse ) . mockResolvedValueOnce ( {
4535+ response : new Response ( JSON . stringify ( { error : { code : "model_not_found" } } ) , { status : 404 } ) ,
4536+ rateLimit : undefined ,
4537+ errorBody : JSON . stringify ( { error : { code : "model_not_found" } } ) ,
4538+ } as never ) ;
4539+ globalThis . fetch = vi
4540+ . fn ( )
4541+ . mockResolvedValueOnce ( new Response ( JSON . stringify ( { error : { code : "model_not_found" } } ) , { status : 404 } ) ) ;
4542+
4543+ const mockClient = createMockClient ( ) ;
4544+ const { OpenAIOAuthPlugin } = await import ( "../index.js" ) ;
4545+ const plugin = await OpenAIOAuthPlugin ( { client : mockClient } as never ) as unknown as PluginType ;
4546+ const sdk = await plugin . auth . loader ( getOAuthAuth , { options : { } , models : { } } ) ;
4547+ const response = await sdk . fetch ! ( "https://api.openai.com/v1/chat/completions" , {
4548+ method : "POST" ,
4549+ body : JSON . stringify ( { model : "gpt-5.1" } ) ,
4550+ } ) ;
4551+
4552+ // Falls through as a plain 404; no rate-limit rotation
4553+ expect ( response . status ) . toBe ( 404 ) ;
4554+ expect ( markRateLimitedWithReason ) . not . toHaveBeenCalled ( ) ;
4555+ expect ( globalThis . fetch ) . toHaveBeenCalledTimes ( 1 ) ;
4556+ } ) ;
4557+
4558+ it ( "does not mark rate-limited when entitlement 404 is remapped to 403" , async ( ) => {
4559+ const { AccountManager } = await import ( "../lib/accounts.js" ) ;
4560+ const fetchHelpersModule = await import ( "../lib/request/fetch-helpers.js" ) ;
4561+
4562+ const markRateLimitedWithReason = vi . fn ( ) ;
4563+ const manager = {
4564+ getAccountCount : ( ) => 1 ,
4565+ getCurrentOrNextForFamilyHybrid : ( ) => ( { index : 0 , accountId : "acc-1" , email : "alpha@example.com" , refreshToken : "refresh-1" } ) ,
4566+ getCurrentOrNextForFamily : ( ) => ( { index : 0 , accountId : "acc-1" , email : "alpha@example.com" , refreshToken : "refresh-1" } ) ,
4567+ getCurrentWorkspace : ( ) => null ,
4568+ getAccountByIndex : ( ) => null ,
4569+ getAccountsSnapshot : ( ) => [ ] ,
4570+ isAccountAvailableForFamily : ( ) => true ,
4571+ toAuthDetails : ( ) => ( { type : "oauth" as const , access : "access-token" , refresh : "refresh-1" , expires : Date . now ( ) + 60_000 } ) ,
4572+ hasRefreshToken : ( ) => true ,
4573+ saveToDiskDebounced : ( ) => { } ,
4574+ updateFromAuth : ( ) => { } ,
4575+ clearAuthFailures : ( ) => { } ,
4576+ incrementAuthFailures : ( ) => 1 ,
4577+ saveToDisk : async ( ) => { } ,
4578+ markAccountCoolingDown : ( ) => { } ,
4579+ markRateLimited : ( ) => { } ,
4580+ markRateLimitedWithReason,
4581+ consumeToken : ( ) => true ,
4582+ refundToken : ( ) => { } ,
4583+ syncCodexCliActiveSelectionForIndex : async ( ) => { } ,
4584+ markSwitched : ( ) => { } ,
4585+ removeAccount : ( ) => { } ,
4586+ recordFailure : ( ) => { } ,
4587+ recordSuccess : ( ) => { } ,
4588+ recordRateLimit : ( ) => { } ,
4589+ getMinWaitTimeForFamily : ( ) => 0 ,
4590+ shouldShowAccountToast : ( ) => false ,
4591+ markToastShown : ( ) => { } ,
4592+ setActiveIndex : ( ) => null ,
4593+ } ;
4594+ vi . spyOn ( AccountManager , "loadFromDisk" ) . mockResolvedValue ( manager as never ) ;
4595+ // Entitlement error: mapUsageLimit404WithBody remaps 404 -> 403 (not 429)
4596+ vi . mocked ( fetchHelpersModule . handleErrorResponse ) . mockResolvedValueOnce ( {
4597+ response : new Response (
4598+ JSON . stringify ( { error : { code : "usage_not_included" , message : "Not included in your plan" } } ) ,
4599+ { status : 403 } ,
4600+ ) ,
4601+ rateLimit : undefined ,
4602+ errorBody : JSON . stringify ( { error : { code : "usage_not_included" , message : "Not included in your plan" } } ) ,
4603+ } as never ) ;
4604+ globalThis . fetch = vi
4605+ . fn ( )
4606+ . mockResolvedValueOnce ( new Response ( JSON . stringify ( { error : { code : "usage_not_included" } } ) , { status : 404 } ) ) ;
4607+
4608+ const mockClient = createMockClient ( ) ;
4609+ const { OpenAIOAuthPlugin } = await import ( "../index.js" ) ;
4610+ const plugin = await OpenAIOAuthPlugin ( { client : mockClient } as never ) as unknown as PluginType ;
4611+ const sdk = await plugin . auth . loader ( getOAuthAuth , { options : { } , models : { } } ) ;
4612+ const response = await sdk . fetch ! ( "https://api.openai.com/v1/chat/completions" , {
4613+ method : "POST" ,
4614+ body : JSON . stringify ( { model : "gpt-5.1" } ) ,
4615+ } ) ;
4616+
4617+ // Entitlement 403 passes through; no rate-limit rotation
4618+ expect ( response . status ) . toBe ( 403 ) ;
4619+ expect ( markRateLimitedWithReason ) . not . toHaveBeenCalled ( ) ;
4620+ expect ( globalThis . fetch ) . toHaveBeenCalledTimes ( 1 ) ;
4621+ } ) ;
4622+
4623+ it ( "rotates account when upstream 404 body contains rate_limit_exceeded keyword" , async ( ) => {
4624+ const { AccountManager } = await import ( "../lib/accounts.js" ) ;
4625+ const fetchHelpersModule = await import ( "../lib/request/fetch-helpers.js" ) ;
4626+ const rateLimitBackoffModule = await import ( "../lib/request/rate-limit-backoff.js" ) ;
4627+
4628+ const markRateLimitedWithReason = vi . fn ( ) ;
4629+ const manager = {
4630+ getAccountCount : ( ) => 1 ,
4631+ getCurrentOrNextForFamilyHybrid : ( ) => ( { index : 0 , accountId : "acc-1" , email : "alpha@example.com" , refreshToken : "refresh-1" } ) ,
4632+ getCurrentOrNextForFamily : ( ) => ( { index : 0 , accountId : "acc-1" , email : "alpha@example.com" , refreshToken : "refresh-1" } ) ,
4633+ getCurrentWorkspace : ( ) => null ,
4634+ getAccountByIndex : ( ) => null ,
4635+ getAccountsSnapshot : ( ) => [ ] ,
4636+ isAccountAvailableForFamily : ( ) => true ,
4637+ toAuthDetails : ( ) => ( { type : "oauth" as const , access : "access-token" , refresh : "refresh-1" , expires : Date . now ( ) + 60_000 } ) ,
4638+ hasRefreshToken : ( ) => true ,
4639+ saveToDiskDebounced : ( ) => { } ,
4640+ updateFromAuth : ( ) => { } ,
4641+ clearAuthFailures : ( ) => { } ,
4642+ incrementAuthFailures : ( ) => 1 ,
4643+ saveToDisk : async ( ) => { } ,
4644+ markAccountCoolingDown : ( ) => { } ,
4645+ markRateLimited : ( ) => { } ,
4646+ markRateLimitedWithReason,
4647+ consumeToken : ( ) => true ,
4648+ refundToken : ( ) => { } ,
4649+ syncCodexCliActiveSelectionForIndex : async ( ) => { } ,
4650+ markSwitched : ( ) => { } ,
4651+ removeAccount : ( ) => { } ,
4652+ recordFailure : ( ) => { } ,
4653+ recordSuccess : ( ) => { } ,
4654+ recordRateLimit : ( ) => { } ,
4655+ getMinWaitTimeForFamily : ( ) => 0 ,
4656+ shouldShowAccountToast : ( ) => true ,
4657+ markToastShown : ( ) => { } ,
4658+ setActiveIndex : ( ) => null ,
4659+ } ;
4660+ vi . spyOn ( AccountManager , "loadFromDisk" ) . mockResolvedValue ( manager as never ) ;
4661+ vi . mocked ( fetchHelpersModule . handleErrorResponse ) . mockResolvedValueOnce ( {
4662+ response : new Response (
4663+ JSON . stringify ( { error : { code : "rate_limit_exceeded" , message : "Rate limit exceeded" } } ) ,
4664+ { status : 429 } ,
4665+ ) ,
4666+ rateLimit : { retryAfterMs : 3 * 60 * 60 * 1000 , code : "rate_limit_exceeded" } ,
4667+ errorBody : JSON . stringify ( { error : { code : "rate_limit_exceeded" , message : "Rate limit exceeded" } } ) ,
4668+ } as never ) ;
4669+ vi . mocked ( rateLimitBackoffModule . getRateLimitBackoff ) . mockReturnValueOnce ( { attempt : 1 , delayMs : 500 } ) ;
4670+ globalThis . fetch = vi
4671+ . fn ( )
4672+ . mockResolvedValueOnce ( new Response ( JSON . stringify ( { error : { code : "rate_limit_exceeded" } } ) , { status : 404 } ) )
4673+ . mockResolvedValueOnce ( new Response ( JSON . stringify ( { content : "ok" } ) , { status : 200 } ) ) ;
4674+
4675+ const mockClient = createMockClient ( ) ;
4676+ const { OpenAIOAuthPlugin } = await import ( "../index.js" ) ;
4677+ const plugin = await OpenAIOAuthPlugin ( { client : mockClient } as never ) as unknown as PluginType ;
4678+ const sdk = await plugin . auth . loader ( getOAuthAuth , { options : { } , models : { } } ) ;
4679+ const response = await sdk . fetch ! ( "https://api.openai.com/v1/chat/completions" , {
4680+ method : "POST" ,
4681+ body : JSON . stringify ( { model : "gpt-5.1" } ) ,
4682+ } ) ;
4683+
4684+ expect ( response . status ) . toBe ( 503 ) ;
4685+ expect ( globalThis . fetch ) . toHaveBeenCalledTimes ( 1 ) ;
4686+ expect ( markRateLimitedWithReason ) . toHaveBeenCalledWith (
4687+ expect . objectContaining ( { index : 0 } ) ,
4688+ 3 * 60 * 60 * 1000 ,
4689+ "gpt-5.1" ,
4690+ expect . any ( String ) ,
4691+ "gpt-5.1" ,
4692+ ) ;
4693+ } ) ;
4694+
44304695 it ( "persists the longer parsed rate-limit cooldown across overlapping requests" , async ( ) => {
44314696 const { AccountManager } = await import ( "../lib/accounts.js" ) ;
44324697 const { AccountManager : ActualAccountManager } =
0 commit comments