@@ -268,3 +268,125 @@ describe('custom middleware', () => {
268268 expect ( responses ) . lengthOf ( 1 )
269269 } )
270270} )
271+
272+ describe ( 'routing group fallback' , ( ) => {
273+ test ( 'should fallback to next provider on retryable error' , async ( ) => {
274+ let attemptCount = 0
275+ const providerAttempts : string [ ] = [ ]
276+
277+ class FailFirstMiddleware implements Middleware {
278+ dispatch ( next : Next ) : Next {
279+ return async ( proxy : DefaultProviderProxy ) => {
280+ attemptCount ++
281+ const baseUrl = ( proxy as unknown as { providerProxy : { baseUrl : string } } ) . providerProxy . baseUrl
282+ providerAttempts . push ( baseUrl )
283+
284+ // First provider should fail with 503
285+ if ( baseUrl . includes ( 'provider1' ) ) {
286+ return {
287+ requestModel : 'gpt-5' ,
288+ requestBody : '{}' ,
289+ unexpectedStatus : 503 ,
290+ responseHeaders : new Headers ( ) ,
291+ responseBody : JSON . stringify ( { error : 'Service unavailable' } ) ,
292+ }
293+ }
294+
295+ // Second provider should succeed
296+ return await next ( proxy )
297+ }
298+ }
299+ }
300+
301+ const ctx = createExecutionContext ( )
302+ const request = new Request < unknown , IncomingRequestCfProperties > ( 'https://example.com/chat/gpt-5' , {
303+ method : 'POST' ,
304+ headers : { Authorization : 'fallback-test' , 'pydantic-ai-gateway-routing-group' : 'test-group' } ,
305+ body : JSON . stringify ( { model : 'gpt-5' , messages : [ { role : 'user' , content : 'Hello' } ] } ) ,
306+ } )
307+
308+ const gatewayEnv = buildGatewayEnv ( env , [ ] , fetch , undefined , [ new FailFirstMiddleware ( ) ] )
309+ const response = await gatewayFetch ( request , new URL ( request . url ) , ctx , gatewayEnv )
310+ await waitOnExecutionContext ( ctx )
311+
312+ expect ( response . status ) . toBe ( 200 )
313+ expect ( attemptCount ) . toBe ( 2 )
314+ expect ( providerAttempts ) . toEqual ( [ 'http://test.example.com/provider1' , 'http://test.example.com/provider2' ] )
315+
316+ // Verify the response came from the second provider
317+ const content = ( await response . json ( ) ) as { choices : [ { message : { content : string } } ] }
318+ expect ( content . choices [ 0 ] . message . content ) . toMatchInlineSnapshot (
319+ `"request URL: http://test.example.com/provider2/gpt-5"` ,
320+ )
321+ } )
322+
323+ test ( 'should not fallback on non-retryable error' , async ( ) => {
324+ let attemptCount = 0
325+
326+ class FailWithBadRequestMiddleware implements Middleware {
327+ dispatch ( _next : Next ) : Next {
328+ return ( _proxy : DefaultProviderProxy ) => {
329+ attemptCount ++
330+ // Return 400 error (non-retryable)
331+ return Promise . resolve ( {
332+ requestModel : 'gpt-5' ,
333+ requestBody : '{}' ,
334+ unexpectedStatus : 400 ,
335+ responseHeaders : new Headers ( ) ,
336+ responseBody : JSON . stringify ( { error : 'Bad request' } ) ,
337+ } )
338+ }
339+ }
340+ }
341+
342+ const ctx = createExecutionContext ( )
343+ const request = new Request < unknown , IncomingRequestCfProperties > ( 'https://example.com/chat/gpt-5' , {
344+ method : 'POST' ,
345+ headers : { Authorization : 'fallback-test' , 'pydantic-ai-gateway-routing-group' : 'test-group' } ,
346+ body : JSON . stringify ( { model : 'gpt-5' , messages : [ { role : 'user' , content : 'Hello' } ] } ) ,
347+ } )
348+
349+ const gatewayEnv = buildGatewayEnv ( env , [ ] , fetch , undefined , [ new FailWithBadRequestMiddleware ( ) ] )
350+ const response = await gatewayFetch ( request , new URL ( request . url ) , ctx , gatewayEnv )
351+ await waitOnExecutionContext ( ctx )
352+
353+ // Should fail immediately without trying fallback
354+ expect ( response . status ) . toBe ( 400 )
355+ expect ( attemptCount ) . toBe ( 1 )
356+ } )
357+
358+ test ( 'should return error if all providers fail' , async ( ) => {
359+ let attemptCount = 0
360+
361+ class FailAllMiddleware implements Middleware {
362+ dispatch ( _next : Next ) : Next {
363+ return ( _proxy : DefaultProviderProxy ) => {
364+ attemptCount ++
365+ // Always return 503
366+ return Promise . resolve ( {
367+ requestModel : 'gpt-5' ,
368+ requestBody : '{}' ,
369+ unexpectedStatus : 503 ,
370+ responseHeaders : new Headers ( ) ,
371+ responseBody : JSON . stringify ( { error : 'Service unavailable' } ) ,
372+ } )
373+ }
374+ }
375+ }
376+
377+ const ctx = createExecutionContext ( )
378+ const request = new Request < unknown , IncomingRequestCfProperties > ( 'https://example.com/chat/gpt-5' , {
379+ method : 'POST' ,
380+ headers : { Authorization : 'fallback-test' , 'pydantic-ai-gateway-routing-group' : 'test-group' } ,
381+ body : JSON . stringify ( { model : 'gpt-5' , messages : [ { role : 'user' , content : 'Hello' } ] } ) ,
382+ } )
383+
384+ const gatewayEnv = buildGatewayEnv ( env , [ ] , fetch , undefined , [ new FailAllMiddleware ( ) ] )
385+ const response = await gatewayFetch ( request , new URL ( request . url ) , ctx , gatewayEnv )
386+ await waitOnExecutionContext ( ctx )
387+
388+ // Should try both providers and fail with last error
389+ expect ( response . status ) . toBe ( 503 )
390+ expect ( attemptCount ) . toBe ( 2 )
391+ } )
392+ } )
0 commit comments