Skip to main content
Ben Nadel at cf.Objective() 2009 (Minneapolis, MN) with: Ted Steinman and Tim Meyer and Andy Pittman
Ben Nadel at cf.Objective() 2009 (Minneapolis, MN) with: Ted Steinman ( @tedsteinmann ) Tim Meyer ( @timlmeyer ) Andy Pittman ( @apittman )

Proof Of Concept: CFHttp With A Retry Policy In ColdFusion

By on
Tags:

Yesterday, I was considering the ergonomics of Tags and Objects in ColdFusion. The thing that got me started down that path was the desire to add retry logic to a CFHttp workflow. And, the fact that extending the base functionality of certain ColdFusion aspects somewhat requires moving to an object-based / method-based interaction. As a fast-follow to that post, I wanted to explore the notion of a "retry policy" in ColdFusion.

I recent releases of Lucee CFML, you can add "listeners" to certain features of the language. These listeners act somewhat like interceptors, allowing you to observe and change the inputs and outputs of an underlying workflow. For example, the Lucee CFML Query Listener allows you to provide .before() and .after() methods that are invoked before and after SQL execution, respectively.

Along the same lines, it could be nice to provide a "listener" to the CFHttp tag that drives retry mechanics. To explore this idea, I'm going to borrow heavily from the Amazon Web Services (AWS) SDK which encodes the concept of a RetryPolicy. This retry policy composes logic that determines which requests can be retried; and, how long the thread should wait before attempting the retry.

To translate this concept into ColdFusion, I'm going to create a ColdFusion component that has two public methods:

  • shouldRetry( request, response, count )
  • delayBeforeNextRetry( request, response, count )

These method names are borrowed directly from the Java SDK. The shouldRetry() method returns a Boolean indicating whether or not the HTTP request should be retried. And, if so, the delayBeforeNextRetry() method returns the number of milliseconds that the ColdFusion thread should pause before making the next HTTP request attempt.

To illustrate this component API, here's my HttpRetryPolicy.cfc, which applies some relatively simplistic logic around retries. Essentially, it's just looking at the underlying HTTP status code and is asking for a retry when certain status codes are present:

component
	output = false
	hint = "I provide hooks for managing the retry behavior of a ColdFusion HTTP request."
	{

	/**
	* I initialize the retry policy with the given properties.
	*/
	public void function init() {

		backoffDurations = [
			200,  // After 1st attempt.
			700,  // After 2nd attempt.
			1000, // After 3rd attempt.
			2000, // After 4th attempt.
			4000  // After 5th attempt.
		];
		maxRetryAttempts = backoffDurations.len();

	}

	// ---
	// PUBLIC METHODS.
	// ---

	/**
	* I determine if the HTTP request should be retried.
	*/
	public boolean function shouldRetry(
		required any httpRequest,
		required any httpResponse,
		required numeric requestsAttempted
		) {

		if ( requestsAttempted > maxRetryAttempts ) {

			return( false );

		}

		if ( httpRequest.getAttributes().method != "get" ) {

			return( false );

		}

		switch ( val( httpResponse.statusCode ) ) {
			case 408: // Request timeout.
			case 429: // Too many requests.
			case 500: // Server error.
			case 503: // Service unavailable.
			case 504: // Gateway timeout.
				return( true );
			break;
			default:
				return( false );
			break;
		}

	}


	/**
	* I determine how long (in milliseconds) the thread should sleep before retrying the
	* HTTP request.
	*/
	public numeric function delayBeforeNextRetry(
		required any httpRequest,
		required any httpResponse,
		required numeric requestsAttempted
		) {

		return( backoffDurations[ requestsAttempted ] );

	}

}

Now, in theory, you could pass a ColdFusion component instance that implements this API to the CFHttp tag as a "listener". But, since Lucee doesn't actually implement a listener for CFHttp, I'm going to fake it by using a component that proxies the native Http.cfc component.

My proxy component will accept an optional instance of the HttpRetryPolicy.cfc component and then expose a .send() method with wraps the underlying .send() call in retry mechanics. The following ColdFusion component is not a complete proxy - it's just enough to demonstrate the concept:

component
	output = false
	hint = "PROOF OF CONCEPT: I provide a proxy to the native HTTP object that adds retry functionality."
	{

	/**
	* I initialize the retryable HTTP request with the given properties.
	*/
	public void function init() {

		variables.httpRequest = new org.lucee.cfml.Http( argumentCollection = arguments );
		variables.retryPolicy = nullValue();

		// For demo debugging and illustration.
		this.traces = [];

	}

	// ---
	// PUBLIC METHODS.
	// ---

	/**
	* I PROXY the underlying HTTP send and wrap it with retry mechanics using the given
	* retry policy (if one has been defined).
	*/
	public any function send() {

		if ( isNull( retryPolicy ) ) {

			return( httpRequest.send() );

		}

		// If the retry policy is malformed (developer error), we need to prevent this
		// thread from falling into an endless loop. As such, we'll only allow a maximum
		// of 10 retries regardless of what the retry policy provides.
		var infiniteLoopCutoff = 10;
		var requestsAttempted = 0;
		var startedAt = getTickCount();

		do {

			requestsAttempted++;

			// For debugging output in the demo so that we can see how many times the
			// underlying HTTP request was executed.
			this.traces.append( "Performing HTTP request (#requestsAttempted# at #( getTickCount() - startedAt )#ms)." );

			var httpResponseWrapper = httpRequest.send();
			var httpResponse = httpResponseWrapper.getPrefix();

			if ( --infiniteLoopCutoff <= 0 ) {

				break;

			}

			if ( ! retryPolicy.shouldRetry( httpRequest, httpResponse, requestsAttempted ) ) {

				break;

			}

			sleep( retryPolicy.delayBeforeNextRetry( httpRequest, httpResponse, requestsAttempted ) );

		} while( true );

		return( httpResponseWrapper );

	}


	/**
	* For the PROOF OF CONCEPT, I'm going to require the retry policy to be set separately
	* so that the `arguments` scope can be used to initialize the underlying HTTP request
	* without having to pluck-out the retry policy.
	*/
	public any function setRetryPolicy( required any retryPolicy ) {

		variables.retryPolicy = arguments.retryPolicy;
		return( this );

	}

}

As you can see, the proxy logic is actually fairly simple:

  1. We make the HTTP request using the native Http.cfc instance.

  2. We ask the retry policy if the given HTTP response should be retried - notice that we don't test the status code in the proxy; all responses are passed to the retry policy.

  3. If the retry is approved, we ask the retry policy how long the thread should sleep().

  4. Repeat.

To test this interplay between the native Http.cfc and my HttpWithRetry.cfc, I created a simple ColdFusion page that would randomly fail 2/3 of the time:

<cfscript>

	if ( randRange( 1, 3 ) == 1 ) {

		header
			statusCode = 200
			statusText = "OK"
		;
		echo( "Success!" );

	} else {

		header
			statusCode = 500
			statusText = "ServerError"
		;
		echo( "Oh no!" );

	}

</cfscript>

And then, a simple CFML page that pulls this all together:

<cfscript>

	// NOTE: We know that "target.cfm" will randomly fail 2/3 of the time.
	httpRequest = new HttpWithRetry(
		method = "GET",
		url = "http://127.0.0.1:62625/retry-policy/target.cfm"
	);

	httpRequest.setRetryPolicy( new HttpRetryPolicy() );

	dump( httpRequest.send().getPrefix() );
	dump( httpRequest.traces );

</cfscript>

In this case, where I'm calling the .setRetryPolicy() method, you might imagine that being replaced with a listener attribute on the CFHttp tag (to follow the existing pattern that Lucee has established with query and listeners). And, if we run this ColdFusion page, we get the following output:

HTTP response output with debugging output showing that 5 underlying HTTP requests were executed.

As you can see, due to our randomly failing target page, our HttpWithRetry.cfc ColdFusion component ended up executing 5 consecutive HTTP requests until the HttpRetryPolicy.cfc rejected the request for another retry.

This implementation is not intended to be feature-complete - it's just a proof of concept. But, I do like the idea that the approach isn't trying to be a one-size-fits-all solution. Instead, the HTTP functionality would be extensible in a way that different API clients in user-land could provide different retry policies.

Want to use code from this post? Check out the license.

Reader Comments

1 Comments

This is actually really cool. I just took your idea and embedded it - currently on a Proof of concept level as well - into Hyper (https://www.forgebox.io/view/hyper).

So, now I can do Hyper requests with a custom HTTP client (which is conceptually similar to what you called the wrapper) with a Retry and Backoff policy attached.

I'll see how I can feed that back into the official library via a PR.

15,688 Comments

@Kai,

Oh very cool! Yeah, I'll be curious to see what you come up with. I've heard people talk about Hyper on the Modernize or Die podcast; but, I haven't looked too closely at it yet. But, given the fluent API, I'm guessing that it could probably use some sort of .withRetryPolicy() kind of method.

Also, I know I owe you an email response from several months ago 😨 2023 was a tough year.

Post A Comment — I'd Love To Hear From You!

Post a Comment

I believe in love. I believe in compassion. I believe in human rights. I believe that we can afford to give more of these gifts to the world around us because it costs us nothing to be decent and kind and understanding. And, I want you to know that when you land on this site, you are accepted for who you are, no matter how you identify, what truths you live, or whatever kind of goofy shit makes you feel alive! Rock on with your bad self!
Ben Nadel