Skip to content

Commit daf90bd

Browse files
committed
feat(HyperRequest): Add retry handling to HyperRequests
HyperRequests can now be set to retry requests. You can provide either a number of retries and a retry delay value (in milliseconds) or an array of retry delay values (to account for exponential backoffs). Additionally, a predicate function can be provided to determine if a request should be retried and to even modify the next request to be sent. (The default predicate function is `return HyperResponse.isError();`.)
1 parent 60100c4 commit daf90bd

5 files changed

Lines changed: 315 additions & 68 deletions

File tree

models/HyperRequest.cfc

Lines changed: 135 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,26 @@ component accessors="true" {
7474
*/
7575
property name="maximumRedirects" default="*";
7676

77+
/**
78+
* An array describing how to retry failed requests.
79+
* Defaults to an empty array meaning no retries will be attempted.
80+
*/
81+
property name="retries";
82+
83+
/**
84+
* The current request count.
85+
* Used for determining if a retry should happen and for how long.
86+
*/
87+
property name="currentRequestCount" default="1";
88+
89+
/**
90+
* A predicate function to determine if the retry should be attempted.
91+
* The next request can also be modified in this predicate function.
92+
* Defaults to retrying if the response has an error status code,
93+
* as determined by `HyperResponse#isError`
94+
*/
95+
property name="retryPredicate";
96+
7797
/**
7898
* The body to send with the request.
7999
* How the body is serialized is
@@ -177,21 +197,28 @@ component accessors="true" {
177197
* @returns The HyperRequest instance.
178198
*/
179199
function init( httpClient = new CfhttpHttpClient() ) {
180-
variables.requestID = createUUID();
181-
variables.httpClient = arguments.httpClient;
182-
variables.queryParams = [];
183-
variables.headers = createObject( "java", "java.util.LinkedHashMap" ).init();
184-
variables.cookies = structNew( "ordered" );
185-
variables.files = [];
186-
variables.requestCallbacks = [];
187-
variables.responseCallbacks = [];
200+
variables.requestID = createUUID();
201+
variables.httpClient = arguments.httpClient;
202+
variables.queryParams = [];
203+
variables.headers = createObject( "java", "java.util.LinkedHashMap" ).init();
204+
variables.cookies = structNew( "ordered" );
205+
variables.files = [];
206+
variables.requestCallbacks = [];
207+
variables.responseCallbacks = [];
208+
variables.retries = [];
209+
variables.retryPredicate = function( res, req, exception ) {
210+
return res.isError();
211+
};
188212

189213
setUserAgent( "HyperCFML/#getHyperVersion()#" );
214+
190215
// This is overwritten by the HyperBuilder if WireBox exists.
191216
variables.interceptorService = {
192217
"processState" : function() {
193218
}
194219
};
220+
221+
// This is overwritten by the HyperBuilder if WireBox exists.
195222
variables.asyncManager = {
196223
"newFuture" : function() {
197224
throw( "No asyncManager set!" );
@@ -1044,18 +1071,53 @@ component accessors="true" {
10441071
}
10451072
variables.interceptorService.processState( "onHyperRequest", { "request" : this } );
10461073

1047-
var res = shouldFake() ? generateFakeRequest() : variables.httpClient.send( this );
1074+
try {
1075+
var res = shouldFake() ? generateFakeRequest() : variables.httpClient.send( this );
10481076

1049-
for ( var callback in variables.responseCallbacks ) {
1050-
callback( res );
1051-
}
1052-
variables.interceptorService.processState( "onHyperResponse", { "response" : res } );
1077+
for ( var callback in variables.responseCallbacks ) {
1078+
callback( res );
1079+
}
1080+
variables.interceptorService.processState( "onHyperResponse", { "response" : res } );
1081+
1082+
if (
1083+
variables.currentRequestCount <= variables.retries.len() &&
1084+
variables.retryPredicate( res, this )
1085+
) {
1086+
sleep( variables.retries[ variables.currentRequestCount ] );
1087+
variables.currentRequestCount++;
1088+
return variables.send();
1089+
}
10531090

1054-
if ( res.isRedirect() && shouldFollowRedirect() ) {
1055-
return followRedirect( res );
1056-
}
1091+
if ( res.isRedirect() && shouldFollowRedirect() ) {
1092+
return followRedirect( res );
1093+
}
1094+
1095+
return res;
1096+
} catch ( HyperRequestError e ) {
1097+
var resMemento = deserializeJSON( e.extendedinfo ).response;
1098+
var res = new Hyper.models.HyperResponse(
1099+
originalRequest = this,
1100+
executionTime = resMemento.executionTime,
1101+
charset = resMemento.charset,
1102+
statusCode = resMemento.statusCode,
1103+
statusText = resMemento.statusText,
1104+
headers = resMemento.headers,
1105+
data = resMemento.data,
1106+
timestamp = resMemento.timestamp,
1107+
responseID = resMemento.responseID
1108+
);
1109+
1110+
if (
1111+
variables.currentRequestCount <= variables.retries.len() &&
1112+
variables.retryPredicate( res, this, e )
1113+
) {
1114+
sleep( variables.retries[ variables.currentRequestCount ] );
1115+
variables.currentRequestCount++;
1116+
return variables.send();
1117+
}
10571118

1058-
return res;
1119+
rethrow;
1120+
}
10591121
}
10601122

10611123
/**
@@ -1129,6 +1191,34 @@ component accessors="true" {
11291191
return this;
11301192
}
11311193

1194+
public HyperRequest function retry(
1195+
required any attempts,
1196+
numeric delay,
1197+
function predicate
1198+
) {
1199+
// convert attempt counts into an array of identical backoff delays
1200+
if ( isSimpleValue( arguments.attempts ) ) {
1201+
if ( isNull( arguments.delay ) || !isNumeric( arguments.delay ) ) {
1202+
throw(
1203+
type = "HyperRetryMissingParameter",
1204+
message = "The `delay` parameter is required when using a numeric attempt count."
1205+
);
1206+
}
1207+
var attemptCount = arguments.attempts;
1208+
arguments.attempts = [];
1209+
for ( var i = 1; i <= attemptCount; i++ ) {
1210+
arguments.attempts.append( arguments.delay );
1211+
}
1212+
}
1213+
1214+
variables.retries = arguments.attempts;
1215+
if ( !isNull( arguments.predicate ) ) {
1216+
variables.retryPredicate = arguments.predicate;
1217+
}
1218+
1219+
return this;
1220+
}
1221+
11321222
/**
11331223
* Clones the current request into a new HyperRequest.
11341224
*
@@ -1164,6 +1254,8 @@ component accessors="true" {
11641254
req.setAuthType( variables.authType );
11651255
req.setRequestCallbacks( duplicate( variables.requestCallbacks ) );
11661256
req.setResponseCallbacks( duplicate( variables.responseCallbacks ) );
1257+
req.setRetries( duplicate( getRetries() ) );
1258+
req.setRetryPredicate( getRetryPredicate() );
11671259
return req;
11681260
}
11691261

@@ -1277,30 +1369,32 @@ component accessors="true" {
12771369
*/
12781370
public struct function getMemento() {
12791371
return {
1280-
"requestID" : getRequestID(),
1281-
"baseUrl" : getBaseUrl(),
1282-
"url" : getUrl(),
1283-
"fullUrl" : getFullUrl(),
1284-
"method" : getMethod(),
1285-
"queryParams" : getQueryParams(),
1286-
"headers" : getHeaders(),
1287-
"cookies" : getCookies(),
1288-
"files" : getFiles(),
1289-
"bodyFormat" : getBodyFormat(),
1290-
"body" : getBody(),
1291-
"referrerId" : isNull( variables.referrer ) ? "" : variables.referrer.getResponseID(),
1292-
"throwOnError" : getThrowOnError(),
1293-
"timeout" : getTimeout(),
1294-
"maximumRedirects" : getMaximumRedirects(),
1295-
"authType" : getAuthType(),
1296-
"username" : getUsername(),
1297-
"password" : getPassword(),
1298-
"clientCert" : isNull( variables.clientCert ) ? "" : variables.clientCert,
1299-
"clientCertPassword" : isNull( variables.clientCertPassword ) ? "" : variables.clientCertPassword,
1300-
"domain" : getDomain(),
1301-
"workstation" : getWorkstation(),
1302-
"resolveUrls" : getResolveUrls(),
1303-
"encodeUrl" : getEncodeUrl()
1372+
"requestID" : getRequestID(),
1373+
"baseUrl" : getBaseUrl(),
1374+
"url" : getUrl(),
1375+
"fullUrl" : getFullUrl(),
1376+
"method" : getMethod(),
1377+
"queryParams" : getQueryParams(),
1378+
"headers" : getHeaders(),
1379+
"cookies" : getCookies(),
1380+
"files" : getFiles(),
1381+
"bodyFormat" : getBodyFormat(),
1382+
"body" : getBody(),
1383+
"referrerId" : isNull( variables.referrer ) ? "" : variables.referrer.getResponseID(),
1384+
"throwOnError" : getThrowOnError(),
1385+
"timeout" : getTimeout(),
1386+
"maximumRedirects" : getMaximumRedirects(),
1387+
"authType" : getAuthType(),
1388+
"username" : getUsername(),
1389+
"password" : getPassword(),
1390+
"clientCert" : isNull( variables.clientCert ) ? "" : variables.clientCert,
1391+
"clientCertPassword" : isNull( variables.clientCertPassword ) ? "" : variables.clientCertPassword,
1392+
"domain" : getDomain(),
1393+
"workstation" : getWorkstation(),
1394+
"resolveUrls" : getResolveUrls(),
1395+
"encodeUrl" : getEncodeUrl(),
1396+
"retries" : getRetries(),
1397+
"currentRequestCount" : getCurrentRequestCount()
13041398
};
13051399
}
13061400

models/HyperResponse.cfc

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,10 @@ component accessors="true" {
8181
string statusText = "OK",
8282
struct headers = {},
8383
any data = "",
84-
timestamp = now()
84+
timestamp = now(),
85+
any responseID = createUUID()
8586
) {
86-
variables.responseID = createUUID();
87+
variables.responseID = arguments.responseID;
8788
variables.request = arguments.originalRequest;
8889
variables.charset = arguments.charset;
8990
variables.statusCode = arguments.statusCode;

server.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"app":{
3-
"cfengine":"adobe@2018"
3+
"cfengine":"adobe@2023"
44
},
55
"web":{
66
"http":{
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
component extends="tests.resources.ModuleIntegrationSpec" appMapping="/app" {
2+
3+
function beforeAll() {
4+
super.beforeAll();
5+
addMatchers( "hyper.models.TestBoxMatchers" );
6+
}
7+
8+
function run() {
9+
describe( "retry requests", () => {
10+
it( "can retry requests", () => {
11+
var hyper = new hyper.models.HyperBuilder();
12+
hyper
13+
.fake( {
14+
"https://needs-retry.dev/" : function( createFakeResponse ) {
15+
return [
16+
createFakeResponse( 500, "Internal Server Error" ),
17+
createFakeResponse( 200, "OK" )
18+
];
19+
}
20+
} )
21+
.preventStrayRequests();
22+
23+
var retryDelays = [];
24+
var onHyperRequestCalls = [];
25+
var onHyperResponseCalls = [];
26+
27+
var res = hyper
28+
.retry( 3, 100 )
29+
.withRequestCallback( ( req ) => retryDelays.append( req.getRetries()[ req.getCurrentRequestCount() ] ) )
30+
.withRequestCallback( ( req ) => onHyperRequestCalls.append( req.getMemento() ) )
31+
.withResponseCallback( ( res ) => onHyperResponseCalls.append( res.getMemento() ) )
32+
.get( "https://needs-retry.dev/" );
33+
34+
expect( res.getStatusCode() ).toBe( 200 );
35+
expect( res.getStatusText() ).toBe( "OK" );
36+
37+
expect( retryDelays ).toBe( [ 100, 100 ] );
38+
expect( onHyperRequestCalls ).toHaveLength( 2 );
39+
expect( onHyperResponseCalls ).toHaveLength( 2 );
40+
} );
41+
42+
it( "can provide a custom array of retry delays", () => {
43+
var hyper = new hyper.models.HyperBuilder();
44+
hyper
45+
.fake( {
46+
"https://needs-retry.dev/" : function( createFakeResponse ) {
47+
return [
48+
createFakeResponse( 500, "Internal Server Error" ),
49+
createFakeResponse( 500, "Internal Server Error" ),
50+
createFakeResponse( 200, "OK" )
51+
];
52+
}
53+
} )
54+
.preventStrayRequests();
55+
56+
var retryDelays = [];
57+
var onHyperRequestCalls = [];
58+
var onHyperResponseCalls = [];
59+
60+
var res = hyper
61+
.retry( [ 100, 200, 300 ] )
62+
.withRequestCallback( ( req ) => retryDelays.append( req.getRetries()[ req.getCurrentRequestCount() ] ) )
63+
.withRequestCallback( ( req ) => onHyperRequestCalls.append( req.getMemento() ) )
64+
.withResponseCallback( ( res ) => onHyperResponseCalls.append( res.getMemento() ) )
65+
.get( "https://needs-retry.dev/" );
66+
67+
expect( res.getStatusCode() ).toBe( 200 );
68+
expect( res.getStatusText() ).toBe( "OK" );
69+
70+
expect( retryDelays ).toBe( [ 100, 200, 300 ] );
71+
expect( onHyperRequestCalls ).toHaveLength( 3 );
72+
expect( onHyperResponseCalls ).toHaveLength( 3 );
73+
} );
74+
75+
it( "can provide a predicate function to determine if a request should be retried", () => {
76+
var hyper = new hyper.models.HyperBuilder();
77+
hyper
78+
.fake( {
79+
"https://needs-retry.dev/" : function( createFakeResponse ) {
80+
return [
81+
createFakeResponse( 500, "Internal Server Error" ),
82+
createFakeResponse( 429, "Too Many Requests" ),
83+
createFakeResponse( 200, "OK" )
84+
];
85+
}
86+
} )
87+
.preventStrayRequests();
88+
89+
var onHyperRequestCalls = [];
90+
var onHyperResponseCalls = [];
91+
92+
var res = hyper
93+
.retry(
94+
3,
95+
100,
96+
function( res, req ) {
97+
return res.isServerError();
98+
}
99+
)
100+
.withRequestCallback( ( req ) => onHyperRequestCalls.append( req.getMemento() ) )
101+
.withResponseCallback( ( res ) => onHyperResponseCalls.append( res.getMemento() ) )
102+
.get( "https://needs-retry.dev/" );
103+
104+
expect( res.getStatusCode() ).toBe( 429 );
105+
expect( res.getStatusText() ).toBe( "Too Many Requests" );
106+
107+
expect( onHyperRequestCalls ).toHaveLength( 2 );
108+
expect( onHyperResponseCalls ).toHaveLength( 2 );
109+
} );
110+
111+
it( "can modify the next request from the predicate function", () => {
112+
var hyper = new hyper.models.HyperBuilder();
113+
hyper
114+
.fake( {
115+
"https://needs-retry.dev/failure" : function( createFakeResponse ) {
116+
return createFakeResponse( 500, "Internal Server Error" );
117+
},
118+
"https://needs-retry.dev/success" : function( createFakeResponse ) {
119+
return createFakeResponse( 200, "OK" );
120+
}
121+
} )
122+
.preventStrayRequests();
123+
124+
var onHyperRequestCalls = [];
125+
var onHyperResponseCalls = [];
126+
127+
var res = hyper
128+
.setBaseUrl( "https://needs-retry.dev" )
129+
.retry(
130+
3,
131+
100,
132+
function( res, req ) {
133+
req.setUrl( "/success" );
134+
return res.isError();
135+
}
136+
)
137+
.withRequestCallback( ( req ) => onHyperRequestCalls.append( req.getMemento() ) )
138+
.withResponseCallback( ( res ) => onHyperResponseCalls.append( res.getMemento() ) )
139+
.get( "/failure" );
140+
141+
expect( res.getStatusCode() ).toBe( 200 );
142+
expect( res.getStatusText() ).toBe( "OK" );
143+
144+
expect( onHyperRequestCalls ).toHaveLength( 2 );
145+
expect( onHyperResponseCalls ).toHaveLength( 2 );
146+
} );
147+
} );
148+
}
149+
150+
}

0 commit comments

Comments
 (0)