Skip to content

Commit

Permalink
feat(HyperRequest): Add retry handling to HyperRequests
Browse files Browse the repository at this point in the history
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();`.)
  • Loading branch information
elpete committed Jan 24, 2024
1 parent 60100c4 commit daf90bd
Show file tree
Hide file tree
Showing 5 changed files with 315 additions and 68 deletions.
176 changes: 135 additions & 41 deletions models/HyperRequest.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,26 @@ component accessors="true" {
*/
property name="maximumRedirects" default="*";

/**
* An array describing how to retry failed requests.
* Defaults to an empty array meaning no retries will be attempted.
*/
property name="retries";

/**
* The current request count.
* Used for determining if a retry should happen and for how long.
*/
property name="currentRequestCount" default="1";

/**
* A predicate function to determine if the retry should be attempted.
* The next request can also be modified in this predicate function.
* Defaults to retrying if the response has an error status code,
* as determined by `HyperResponse#isError`
*/
property name="retryPredicate";

/**
* The body to send with the request.
* How the body is serialized is
Expand Down Expand Up @@ -177,21 +197,28 @@ component accessors="true" {
* @returns The HyperRequest instance.
*/
function init( httpClient = new CfhttpHttpClient() ) {
variables.requestID = createUUID();
variables.httpClient = arguments.httpClient;
variables.queryParams = [];
variables.headers = createObject( "java", "java.util.LinkedHashMap" ).init();
variables.cookies = structNew( "ordered" );
variables.files = [];
variables.requestCallbacks = [];
variables.responseCallbacks = [];
variables.requestID = createUUID();
variables.httpClient = arguments.httpClient;
variables.queryParams = [];
variables.headers = createObject( "java", "java.util.LinkedHashMap" ).init();
variables.cookies = structNew( "ordered" );
variables.files = [];
variables.requestCallbacks = [];
variables.responseCallbacks = [];
variables.retries = [];
variables.retryPredicate = function( res, req, exception ) {
return res.isError();
};

setUserAgent( "HyperCFML/#getHyperVersion()#" );

// This is overwritten by the HyperBuilder if WireBox exists.
variables.interceptorService = {
"processState" : function() {
}
};

// This is overwritten by the HyperBuilder if WireBox exists.
variables.asyncManager = {
"newFuture" : function() {
throw( "No asyncManager set!" );
Expand Down Expand Up @@ -1044,18 +1071,53 @@ component accessors="true" {
}
variables.interceptorService.processState( "onHyperRequest", { "request" : this } );

var res = shouldFake() ? generateFakeRequest() : variables.httpClient.send( this );
try {
var res = shouldFake() ? generateFakeRequest() : variables.httpClient.send( this );

for ( var callback in variables.responseCallbacks ) {
callback( res );
}
variables.interceptorService.processState( "onHyperResponse", { "response" : res } );
for ( var callback in variables.responseCallbacks ) {
callback( res );
}
variables.interceptorService.processState( "onHyperResponse", { "response" : res } );

if (
variables.currentRequestCount <= variables.retries.len() &&
variables.retryPredicate( res, this )
) {
sleep( variables.retries[ variables.currentRequestCount ] );
variables.currentRequestCount++;
return variables.send();
}

if ( res.isRedirect() && shouldFollowRedirect() ) {
return followRedirect( res );
}
if ( res.isRedirect() && shouldFollowRedirect() ) {
return followRedirect( res );
}

return res;
} catch ( HyperRequestError e ) {
var resMemento = deserializeJSON( e.extendedinfo ).response;
var res = new Hyper.models.HyperResponse(
originalRequest = this,
executionTime = resMemento.executionTime,
charset = resMemento.charset,
statusCode = resMemento.statusCode,
statusText = resMemento.statusText,
headers = resMemento.headers,
data = resMemento.data,
timestamp = resMemento.timestamp,
responseID = resMemento.responseID
);

if (
variables.currentRequestCount <= variables.retries.len() &&
variables.retryPredicate( res, this, e )
) {
sleep( variables.retries[ variables.currentRequestCount ] );
variables.currentRequestCount++;
return variables.send();
}

return res;
rethrow;
}
}

/**
Expand Down Expand Up @@ -1129,6 +1191,34 @@ component accessors="true" {
return this;
}

public HyperRequest function retry(
required any attempts,
numeric delay,
function predicate
) {
// convert attempt counts into an array of identical backoff delays
if ( isSimpleValue( arguments.attempts ) ) {
if ( isNull( arguments.delay ) || !isNumeric( arguments.delay ) ) {
throw(
type = "HyperRetryMissingParameter",
message = "The `delay` parameter is required when using a numeric attempt count."
);
}
var attemptCount = arguments.attempts;
arguments.attempts = [];
for ( var i = 1; i <= attemptCount; i++ ) {
arguments.attempts.append( arguments.delay );
}
}

variables.retries = arguments.attempts;
if ( !isNull( arguments.predicate ) ) {
variables.retryPredicate = arguments.predicate;
}

return this;
}

/**
* Clones the current request into a new HyperRequest.
*
Expand Down Expand Up @@ -1164,6 +1254,8 @@ component accessors="true" {
req.setAuthType( variables.authType );
req.setRequestCallbacks( duplicate( variables.requestCallbacks ) );
req.setResponseCallbacks( duplicate( variables.responseCallbacks ) );
req.setRetries( duplicate( getRetries() ) );
req.setRetryPredicate( getRetryPredicate() );
return req;
}

Expand Down Expand Up @@ -1277,30 +1369,32 @@ component accessors="true" {
*/
public struct function getMemento() {
return {
"requestID" : getRequestID(),
"baseUrl" : getBaseUrl(),
"url" : getUrl(),
"fullUrl" : getFullUrl(),
"method" : getMethod(),
"queryParams" : getQueryParams(),
"headers" : getHeaders(),
"cookies" : getCookies(),
"files" : getFiles(),
"bodyFormat" : getBodyFormat(),
"body" : getBody(),
"referrerId" : isNull( variables.referrer ) ? "" : variables.referrer.getResponseID(),
"throwOnError" : getThrowOnError(),
"timeout" : getTimeout(),
"maximumRedirects" : getMaximumRedirects(),
"authType" : getAuthType(),
"username" : getUsername(),
"password" : getPassword(),
"clientCert" : isNull( variables.clientCert ) ? "" : variables.clientCert,
"clientCertPassword" : isNull( variables.clientCertPassword ) ? "" : variables.clientCertPassword,
"domain" : getDomain(),
"workstation" : getWorkstation(),
"resolveUrls" : getResolveUrls(),
"encodeUrl" : getEncodeUrl()
"requestID" : getRequestID(),
"baseUrl" : getBaseUrl(),
"url" : getUrl(),
"fullUrl" : getFullUrl(),
"method" : getMethod(),
"queryParams" : getQueryParams(),
"headers" : getHeaders(),
"cookies" : getCookies(),
"files" : getFiles(),
"bodyFormat" : getBodyFormat(),
"body" : getBody(),
"referrerId" : isNull( variables.referrer ) ? "" : variables.referrer.getResponseID(),
"throwOnError" : getThrowOnError(),
"timeout" : getTimeout(),
"maximumRedirects" : getMaximumRedirects(),
"authType" : getAuthType(),
"username" : getUsername(),
"password" : getPassword(),
"clientCert" : isNull( variables.clientCert ) ? "" : variables.clientCert,
"clientCertPassword" : isNull( variables.clientCertPassword ) ? "" : variables.clientCertPassword,
"domain" : getDomain(),
"workstation" : getWorkstation(),
"resolveUrls" : getResolveUrls(),
"encodeUrl" : getEncodeUrl(),
"retries" : getRetries(),
"currentRequestCount" : getCurrentRequestCount()
};
}

Expand Down
5 changes: 3 additions & 2 deletions models/HyperResponse.cfc
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,10 @@ component accessors="true" {
string statusText = "OK",
struct headers = {},
any data = "",
timestamp = now()
timestamp = now(),
any responseID = createUUID()
) {
variables.responseID = createUUID();
variables.responseID = arguments.responseID;
variables.request = arguments.originalRequest;
variables.charset = arguments.charset;
variables.statusCode = arguments.statusCode;
Expand Down
2 changes: 1 addition & 1 deletion server.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"app":{
"cfengine":"adobe@2018"
"cfengine":"adobe@2023"
},
"web":{
"http":{
Expand Down
150 changes: 150 additions & 0 deletions tests/specs/integration/RetrySpec.cfc
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
component extends="tests.resources.ModuleIntegrationSpec" appMapping="/app" {

function beforeAll() {
super.beforeAll();
addMatchers( "hyper.models.TestBoxMatchers" );
}

function run() {
describe( "retry requests", () => {
it( "can retry requests", () => {
var hyper = new hyper.models.HyperBuilder();
hyper
.fake( {
"https://needs-retry.dev/" : function( createFakeResponse ) {
return [
createFakeResponse( 500, "Internal Server Error" ),
createFakeResponse( 200, "OK" )
];
}
} )
.preventStrayRequests();

var retryDelays = [];
var onHyperRequestCalls = [];
var onHyperResponseCalls = [];

var res = hyper
.retry( 3, 100 )
.withRequestCallback( ( req ) => retryDelays.append( req.getRetries()[ req.getCurrentRequestCount() ] ) )
.withRequestCallback( ( req ) => onHyperRequestCalls.append( req.getMemento() ) )
.withResponseCallback( ( res ) => onHyperResponseCalls.append( res.getMemento() ) )
.get( "https://needs-retry.dev/" );

expect( res.getStatusCode() ).toBe( 200 );
expect( res.getStatusText() ).toBe( "OK" );

expect( retryDelays ).toBe( [ 100, 100 ] );
expect( onHyperRequestCalls ).toHaveLength( 2 );
expect( onHyperResponseCalls ).toHaveLength( 2 );
} );

it( "can provide a custom array of retry delays", () => {
var hyper = new hyper.models.HyperBuilder();
hyper
.fake( {
"https://needs-retry.dev/" : function( createFakeResponse ) {
return [
createFakeResponse( 500, "Internal Server Error" ),
createFakeResponse( 500, "Internal Server Error" ),
createFakeResponse( 200, "OK" )
];
}
} )
.preventStrayRequests();

var retryDelays = [];
var onHyperRequestCalls = [];
var onHyperResponseCalls = [];

var res = hyper
.retry( [ 100, 200, 300 ] )
.withRequestCallback( ( req ) => retryDelays.append( req.getRetries()[ req.getCurrentRequestCount() ] ) )
.withRequestCallback( ( req ) => onHyperRequestCalls.append( req.getMemento() ) )
.withResponseCallback( ( res ) => onHyperResponseCalls.append( res.getMemento() ) )
.get( "https://needs-retry.dev/" );

expect( res.getStatusCode() ).toBe( 200 );
expect( res.getStatusText() ).toBe( "OK" );

expect( retryDelays ).toBe( [ 100, 200, 300 ] );
expect( onHyperRequestCalls ).toHaveLength( 3 );
expect( onHyperResponseCalls ).toHaveLength( 3 );
} );

it( "can provide a predicate function to determine if a request should be retried", () => {
var hyper = new hyper.models.HyperBuilder();
hyper
.fake( {
"https://needs-retry.dev/" : function( createFakeResponse ) {
return [
createFakeResponse( 500, "Internal Server Error" ),
createFakeResponse( 429, "Too Many Requests" ),
createFakeResponse( 200, "OK" )
];
}
} )
.preventStrayRequests();

var onHyperRequestCalls = [];
var onHyperResponseCalls = [];

var res = hyper
.retry(
3,
100,
function( res, req ) {
return res.isServerError();
}
)
.withRequestCallback( ( req ) => onHyperRequestCalls.append( req.getMemento() ) )
.withResponseCallback( ( res ) => onHyperResponseCalls.append( res.getMemento() ) )
.get( "https://needs-retry.dev/" );

expect( res.getStatusCode() ).toBe( 429 );
expect( res.getStatusText() ).toBe( "Too Many Requests" );

expect( onHyperRequestCalls ).toHaveLength( 2 );
expect( onHyperResponseCalls ).toHaveLength( 2 );
} );

it( "can modify the next request from the predicate function", () => {
var hyper = new hyper.models.HyperBuilder();
hyper
.fake( {
"https://needs-retry.dev/failure" : function( createFakeResponse ) {
return createFakeResponse( 500, "Internal Server Error" );
},
"https://needs-retry.dev/success" : function( createFakeResponse ) {
return createFakeResponse( 200, "OK" );
}
} )
.preventStrayRequests();

var onHyperRequestCalls = [];
var onHyperResponseCalls = [];

var res = hyper
.setBaseUrl( "https://needs-retry.dev" )
.retry(
3,
100,
function( res, req ) {
req.setUrl( "/success" );
return res.isError();
}
)
.withRequestCallback( ( req ) => onHyperRequestCalls.append( req.getMemento() ) )
.withResponseCallback( ( res ) => onHyperResponseCalls.append( res.getMemento() ) )
.get( "/failure" );

expect( res.getStatusCode() ).toBe( 200 );
expect( res.getStatusText() ).toBe( "OK" );

expect( onHyperRequestCalls ).toHaveLength( 2 );
expect( onHyperResponseCalls ).toHaveLength( 2 );
} );
} );
}

}
Loading

0 comments on commit daf90bd

Please sign in to comment.