@@ -958,4 +958,231 @@ describe('MCP Tool Execution', () => {
958958 expect ( result . error ) . toContain ( 'Network error' )
959959 expect ( result . timing ) . toBeDefined ( )
960960 } )
961+
962+ describe ( 'Tool request retries' , ( ) => {
963+ function makeJsonResponse (
964+ status : number ,
965+ body : unknown ,
966+ extraHeaders ?: Record < string , string >
967+ ) : any {
968+ const headers = new Headers ( { 'content-type' : 'application/json' , ...( extraHeaders ?? { } ) } )
969+ return {
970+ ok : status >= 200 && status < 300 ,
971+ status,
972+ statusText : status >= 200 && status < 300 ? 'OK' : 'Error' ,
973+ headers,
974+ json : ( ) => Promise . resolve ( body ) ,
975+ text : ( ) => Promise . resolve ( typeof body === 'string' ? body : JSON . stringify ( body ) ) ,
976+ arrayBuffer : ( ) => Promise . resolve ( new ArrayBuffer ( 0 ) ) ,
977+ blob : ( ) => Promise . resolve ( new Blob ( ) ) ,
978+ }
979+ }
980+
981+ it ( 'retries on 5xx responses for http_request' , async ( ) => {
982+ global . fetch = Object . assign (
983+ vi
984+ . fn ( )
985+ . mockResolvedValueOnce ( makeJsonResponse ( 500 , { error : 'nope' } ) )
986+ . mockResolvedValueOnce ( makeJsonResponse ( 200 , { ok : true } ) ) ,
987+ { preconnect : vi . fn ( ) }
988+ ) as typeof fetch
989+
990+ const result = await executeTool ( 'http_request' , {
991+ url : '/api/test' ,
992+ method : 'GET' ,
993+ retries : 2 ,
994+ retryDelayMs : 0 ,
995+ retryMaxDelayMs : 0 ,
996+ } )
997+
998+ expect ( global . fetch ) . toHaveBeenCalledTimes ( 2 )
999+ expect ( result . success ) . toBe ( true )
1000+ expect ( ( result . output as any ) . status ) . toBe ( 200 )
1001+ } )
1002+
1003+ it ( 'does not retry when retries is not specified (default: 0)' , async ( ) => {
1004+ global . fetch = Object . assign (
1005+ vi . fn ( ) . mockResolvedValue ( makeJsonResponse ( 500 , { error : 'server error' } ) ) ,
1006+ { preconnect : vi . fn ( ) }
1007+ ) as typeof fetch
1008+
1009+ const result = await executeTool ( 'http_request' , {
1010+ url : '/api/test' ,
1011+ method : 'GET' ,
1012+ } )
1013+
1014+ expect ( global . fetch ) . toHaveBeenCalledTimes ( 1 )
1015+ expect ( result . success ) . toBe ( false )
1016+ } )
1017+
1018+ it ( 'stops retrying after max attempts for http_request' , async ( ) => {
1019+ global . fetch = Object . assign (
1020+ vi . fn ( ) . mockResolvedValue ( makeJsonResponse ( 502 , { error : 'bad gateway' } ) ) ,
1021+ { preconnect : vi . fn ( ) }
1022+ ) as typeof fetch
1023+
1024+ const result = await executeTool ( 'http_request' , {
1025+ url : '/api/test' ,
1026+ method : 'GET' ,
1027+ retries : 2 ,
1028+ retryDelayMs : 0 ,
1029+ retryMaxDelayMs : 0 ,
1030+ } )
1031+
1032+ expect ( global . fetch ) . toHaveBeenCalledTimes ( 3 )
1033+ expect ( result . success ) . toBe ( false )
1034+ } )
1035+
1036+ it ( 'does not retry on 4xx responses for http_request' , async ( ) => {
1037+ global . fetch = Object . assign (
1038+ vi . fn ( ) . mockResolvedValue ( makeJsonResponse ( 400 , { error : 'bad request' } ) ) ,
1039+ { preconnect : vi . fn ( ) }
1040+ ) as typeof fetch
1041+
1042+ const result = await executeTool ( 'http_request' , {
1043+ url : '/api/test' ,
1044+ method : 'GET' ,
1045+ retries : 5 ,
1046+ retryDelayMs : 0 ,
1047+ retryMaxDelayMs : 0 ,
1048+ } )
1049+
1050+ expect ( global . fetch ) . toHaveBeenCalledTimes ( 1 )
1051+ expect ( result . success ) . toBe ( false )
1052+ } )
1053+
1054+ it ( 'does not retry POST by default (non-idempotent)' , async ( ) => {
1055+ global . fetch = Object . assign (
1056+ vi
1057+ . fn ( )
1058+ . mockResolvedValueOnce ( makeJsonResponse ( 500 , { error : 'nope' } ) )
1059+ . mockResolvedValueOnce ( makeJsonResponse ( 200 , { ok : true } ) ) ,
1060+ { preconnect : vi . fn ( ) }
1061+ ) as typeof fetch
1062+
1063+ const result = await executeTool ( 'http_request' , {
1064+ url : '/api/test' ,
1065+ method : 'POST' ,
1066+ retries : 2 ,
1067+ retryDelayMs : 0 ,
1068+ retryMaxDelayMs : 0 ,
1069+ } )
1070+
1071+ expect ( global . fetch ) . toHaveBeenCalledTimes ( 1 )
1072+ expect ( result . success ) . toBe ( false )
1073+ } )
1074+
1075+ it ( 'retries POST when retryNonIdempotent is enabled' , async ( ) => {
1076+ global . fetch = Object . assign (
1077+ vi
1078+ . fn ( )
1079+ . mockResolvedValueOnce ( makeJsonResponse ( 500 , { error : 'nope' } ) )
1080+ . mockResolvedValueOnce ( makeJsonResponse ( 200 , { ok : true } ) ) ,
1081+ { preconnect : vi . fn ( ) }
1082+ ) as typeof fetch
1083+
1084+ const result = await executeTool ( 'http_request' , {
1085+ url : '/api/test' ,
1086+ method : 'POST' ,
1087+ retries : 1 ,
1088+ retryNonIdempotent : true ,
1089+ retryDelayMs : 0 ,
1090+ retryMaxDelayMs : 0 ,
1091+ } )
1092+
1093+ expect ( global . fetch ) . toHaveBeenCalledTimes ( 2 )
1094+ expect ( result . success ) . toBe ( true )
1095+ expect ( ( result . output as any ) . status ) . toBe ( 200 )
1096+ } )
1097+
1098+ it ( 'retries on timeout errors for http_request' , async ( ) => {
1099+ const abortError = Object . assign ( new Error ( 'Aborted' ) , { name : 'AbortError' } )
1100+ global . fetch = Object . assign (
1101+ vi
1102+ . fn ( )
1103+ . mockRejectedValueOnce ( abortError )
1104+ . mockResolvedValueOnce ( makeJsonResponse ( 200 , { ok : true } ) ) ,
1105+ { preconnect : vi . fn ( ) }
1106+ ) as typeof fetch
1107+
1108+ const result = await executeTool ( 'http_request' , {
1109+ url : '/api/test' ,
1110+ method : 'GET' ,
1111+ retries : 1 ,
1112+ retryDelayMs : 0 ,
1113+ retryMaxDelayMs : 0 ,
1114+ } )
1115+
1116+ expect ( global . fetch ) . toHaveBeenCalledTimes ( 2 )
1117+ expect ( result . success ) . toBe ( true )
1118+ } )
1119+
1120+ it ( 'skips retry when Retry-After header exceeds maxDelayMs' , async ( ) => {
1121+ global . fetch = Object . assign (
1122+ vi
1123+ . fn ( )
1124+ . mockResolvedValueOnce (
1125+ makeJsonResponse ( 429 , { error : 'rate limited' } , { 'retry-after' : '60' } )
1126+ )
1127+ . mockResolvedValueOnce ( makeJsonResponse ( 200 , { ok : true } ) ) ,
1128+ { preconnect : vi . fn ( ) }
1129+ ) as typeof fetch
1130+
1131+ const result = await executeTool ( 'http_request' , {
1132+ url : '/api/test' ,
1133+ method : 'GET' ,
1134+ retries : 3 ,
1135+ retryMaxDelayMs : 5000 ,
1136+ } )
1137+
1138+ expect ( global . fetch ) . toHaveBeenCalledTimes ( 1 )
1139+ expect ( result . success ) . toBe ( false )
1140+ } )
1141+
1142+ it ( 'retries when Retry-After header is within maxDelayMs' , async ( ) => {
1143+ global . fetch = Object . assign (
1144+ vi
1145+ . fn ( )
1146+ . mockResolvedValueOnce (
1147+ makeJsonResponse ( 429 , { error : 'rate limited' } , { 'retry-after' : '1' } )
1148+ )
1149+ . mockResolvedValueOnce ( makeJsonResponse ( 200 , { ok : true } ) ) ,
1150+ { preconnect : vi . fn ( ) }
1151+ ) as typeof fetch
1152+
1153+ const result = await executeTool ( 'http_request' , {
1154+ url : '/api/test' ,
1155+ method : 'GET' ,
1156+ retries : 2 ,
1157+ retryMaxDelayMs : 5000 ,
1158+ } )
1159+
1160+ expect ( global . fetch ) . toHaveBeenCalledTimes ( 2 )
1161+ expect ( result . success ) . toBe ( true )
1162+ } )
1163+
1164+ it ( 'retries on ETIMEDOUT errors for http_request' , async ( ) => {
1165+ const etimedoutError = Object . assign ( new Error ( 'connect ETIMEDOUT 10.0.0.1:443' ) , {
1166+ code : 'ETIMEDOUT' ,
1167+ } )
1168+ global . fetch = Object . assign (
1169+ vi
1170+ . fn ( )
1171+ . mockRejectedValueOnce ( etimedoutError )
1172+ . mockResolvedValueOnce ( makeJsonResponse ( 200 , { ok : true } ) ) ,
1173+ { preconnect : vi . fn ( ) }
1174+ ) as typeof fetch
1175+
1176+ const result = await executeTool ( 'http_request' , {
1177+ url : '/api/test' ,
1178+ method : 'GET' ,
1179+ retries : 1 ,
1180+ retryDelayMs : 0 ,
1181+ retryMaxDelayMs : 0 ,
1182+ } )
1183+
1184+ expect ( global . fetch ) . toHaveBeenCalledTimes ( 2 )
1185+ expect ( result . success ) . toBe ( true )
1186+ } )
1187+ } )
9611188} )
0 commit comments