@@ -560,4 +560,84 @@ describe('Dispatcher Failover', () => {
560560 expect ( meta ?. attemptCount ) . toBe ( 1 ) ;
561561 expect ( meta ?. finalAttemptProvider ) . toBe ( 'p2' ) ;
562562 } ) ;
563+
564+ test ( 'provider error captures all upstream response headers in routing context' , async ( ) => {
565+ setConfigForTesting ( makeConfig ( { targetCount : 1 } ) ) ;
566+ fetchMock . mockImplementation (
567+ async ( ) =>
568+ new Response ( JSON . stringify ( { error : { message : 'boom' } } ) , {
569+ status : 500 ,
570+ headers : {
571+ 'Content-Type' : 'application/json' ,
572+ 'x-request-id' : 'provider-req-123' ,
573+ 'X-Custom-Header' : 'custom-value' ,
574+ } ,
575+ } )
576+ ) ;
577+
578+ const dispatcher = new Dispatcher ( ) ;
579+
580+ try {
581+ await dispatcher . dispatch ( { ...makeChatRequest ( ) , requestId : 'req-headers-test' } ) ;
582+ throw new Error ( 'expected dispatch to fail' ) ;
583+ } catch ( error : any ) {
584+ expect ( error . routingContext ?. providerResponseHeaders ) . toBeDefined ( ) ;
585+ expect ( error . routingContext ?. providerResponseHeaders [ 'x-request-id' ] ) . toBe ( 'provider-req-123' ) ;
586+ expect ( error . routingContext ?. providerResponseHeaders [ 'x-custom-header' ] ) . toBe ( 'custom-value' ) ;
587+ expect ( error . routingContext ?. providerResponseHeaders [ 'content-type' ] ) . toBe ( 'application/json' ) ;
588+ }
589+ } ) ;
590+
591+ test ( 'retry history includes provider response headers on failed attempts' , async ( ) => {
592+ setConfigForTesting ( makeConfig ( { targetCount : 2 } ) ) ;
593+ fetchMock
594+ . mockImplementationOnce (
595+ async ( ) =>
596+ new Response ( JSON . stringify ( { error : { message : 'first boom' } } ) , {
597+ status : 500 ,
598+ headers : { 'x-request-id' : 'first-req-id' } ,
599+ } )
600+ )
601+ . mockImplementationOnce ( async ( ) => successChatResponse ( 'model-2' ) ) ;
602+
603+ const dispatcher = new Dispatcher ( ) ;
604+ const response = await dispatcher . dispatch ( { ...makeChatRequest ( ) , requestId : 'req-retry-hist' } ) ;
605+ const meta = ( response as any ) . plexus ;
606+ const retryHistory = JSON . parse ( meta ?. retryHistory || '[]' ) ;
607+
608+ expect ( retryHistory ) . toHaveLength ( 2 ) ;
609+ expect ( retryHistory [ 0 ] ?. status ) . toBe ( 'failed' ) ;
610+ expect ( retryHistory [ 0 ] ?. providerResponseHeaders ?. [ 'x-request-id' ] ) . toBe ( 'first-req-id' ) ;
611+ expect ( retryHistory [ 1 ] ?. status ) . toBe ( 'success' ) ;
612+ } ) ;
613+
614+ test ( 'intermediate failures are saved during failover' , async ( ) => {
615+ setConfigForTesting ( makeConfig ( { targetCount : 2 } ) ) ;
616+ fetchMock
617+ . mockImplementationOnce ( async ( ) => errorResponse ( 500 , 'first failed' ) )
618+ . mockImplementationOnce ( async ( ) => successChatResponse ( 'model-2' ) ) ;
619+
620+ const saveErrorSpy = vi . fn ( ) ;
621+ const dispatcher = new Dispatcher ( ) ;
622+ dispatcher . setUsageStorage ( {
623+ saveError : saveErrorSpy ,
624+ recordFailedAttempt : vi . fn ( ) ,
625+ recordSuccessfulAttempt : vi . fn ( ) ,
626+ } as any ) ;
627+
628+ const response = await dispatcher . dispatch ( {
629+ ...makeChatRequest ( ) ,
630+ requestId : 'req-intermediate' ,
631+ } ) ;
632+ const meta = ( response as any ) . plexus ;
633+
634+ expect ( meta ?. attemptCount ) . toBe ( 2 ) ;
635+ // One call for the intermediate failure, one in the route handler doesn't happen
636+ // here because dispatch succeeded overall.
637+ expect ( saveErrorSpy ) . toHaveBeenCalledTimes ( 1 ) ;
638+ const [ savedRequestId , savedError , savedDetails ] = saveErrorSpy . mock . calls [ 0 ] as any [ ] ;
639+ expect ( savedRequestId ) . toBe ( 'req-intermediate' ) ;
640+ expect ( savedError ?. routingContext ?. statusCode ) . toBe ( 500 ) ;
641+ expect ( savedDetails ?. apiType ) . toBe ( 'chat' ) ;
642+ } ) ;
563643} ) ;
0 commit comments