Canceling API Requests Using fetch() And AbortController In JavaScript
The other day, I looked at using fetch()
to build an opinionated API client as a replacement for the jQuery.ajax()
function. When using jQuery to initiate an AJAX call, the return value is Promise
-like, and has an injected .abort()
method for canceling the underlying request. The fetch()
API cannot do this type of injection because it returns a native Promise
. Instead, the fetch()
API requires that we pass-in a signal
if the calling context wants low-level control over the underlying HTTP call. As such, I've updated my ApiClient
class to accept a signal
option; and, to report an .isAbort
property within its rejection handlers.
When initiating a fetch()
call, one of the initialization options is signal
. This is an instance of AbortSignal
, which can be gotten from an instance of AbortController
. You can think of AbortSignal
as a super simple Publish and Subscribe (Pub/Sub) mechanism that only ever emits a single event: abort
.
Once the signal
is passed into the fetch()
method, Fetch subscribes to that abort
event; and, if it detects the abort
event, it will - in turn - cancel the underlying HTTP request. The AbortController
is how we trigger that abort
event on the signal
after it has been passed into the fetch()
method.
ASIDE: If the HTTP request has already been aborted, or has already completed, the
abort
event is safely ignored.
In my ApiClient
, I am now allowing for an instance of the AbortSignal
to be passed in as part of the makeRequest()
configuration. This signal
is then passed-through to the underlying fetch()
call, thereby allowing the underlying HTTP request to be canceled from the calling context (truncated code):
async makeRequest( config ) {
// Extract options, with defaults, from config.
// ....
var signal = ( config.signal || null );
// ....
var fetchHeaders = this.buildHeaders( headers );
var fetchMethod = null;
var fetchUrl = this.mergeParamsIntoUrl( url, params );
var fetchBody = null;
var fetchSignal = signal;
// ....
// ....
// ....
var fetchRequest = new window.Request(
fetchUrl,
{
headers: fetchHeaders,
method: fetchMethod,
body: fetchBody,
signal: fetchSignal // <----- Provides a way to abort HTTP request.
}
);
var fetchResponse = await window.fetch( fetchRequest );
var data = await this.unwrapResponseData( fetchResponse );
// ....
}
In addition to the signal
configuration option, I'm also adding an .isAbort
property to the normalized error structure so that error handlers don't have to get mired in the details of the abort workflow. When the underlying HTTP request is aborted, the fetch()
Promise is rejected with an AbortError
. I can then check for this type of error when I "unwrap" the rejection reason:
/**
* If our request never makes it to the server (or the round-trip is interrupted
* somehow), we still want the error response to have a consistent structure with the
* application errors returned by the server. At a minimum, we want every error to
* have the following properties:
*
* - data.type
* - data.message
* - status.code
* - status.text
* - status.isAbort
*/
normalizeTransportError( transportError ) {
return({
data: {
type: "TransportError",
message: UNEXPECTED_ERROR_MESSAGE,
rootCause: transportError
},
status: {
code: 0,
text: "Unknown",
isAbort: ( transportError.name === "AbortError" )
}
});
}
Here, you can see that we're inspecting the transportError
object to see if it's name is AbortError
. This allows our calling context to only have to check the status.isAbort
flag to see if the fetch()
Promise was rejected due to the HTTP request being aborted.
For non-aborted HTTP requests, this flag is just hard-coded to false
- here's my "error unwrapping" method for non-network-related errors:
/**
* At a minimum, we want every error to have the following properties:
*
* - data.type
* - data.message
* - status.code
* - status.text
* - status.isAbort
*
* These are the keys that the calling context will depend on; and, are the minimum
* keys that the server is expected to return when it throws domain errors.
*/
normalizeError( data, fetchRequest, fetchResponse ) {
var error = {
data: {
type: "ServerError",
message: UNEXPECTED_ERROR_MESSAGE
},
status: {
code: fetchResponse.status,
text: fetchResponse.statusText,
isAbort: false
},
// The following data is being provided to make debugging AJAX errors easier.
request: fetchRequest,
response: fetchResponse
};
// If the error data is an Object (which it should be if the server responded
// with a domain-based error), then it should have "type" and "message"
// properties within it. That said, just because this isn't a transport error, it
// doesn't mean that this error is actually being returned by our application.
if (
( typeof( data?.type ) === "string" ) &&
( typeof( data?.message ) === "string" )
) {
Object.assign( error.data, data );
// If the error data has any other shape, it means that an unexpected error
// occurred on the server (or somewhere in transit). Let's pass that raw error
// through as the rootCause, using the default error structure.
} else {
error.data.rootCause = data;
}
return( error );
}
On my blog, this becomes relevant because I'm showing you a preview of your comment as you type. This preview requires an AJAX call to the server so that your markdown can be converted to HTML and then sanitized using OWASP AntiSamy and ColdFusion. This AJAX is debounced at 300ms so that I'm not triggering a network request on every key-stroke. However, if one AJAX call hasn't completed by the time the next one is triggered, I want to abort the earlier one so that I don't have to worry about race-conditions wherein the various AJAX requests return out-of-order.
To accomplish this with the update ApiClient
semantics, I'm tracking an instance of AbortController
. And, if the AbortController
is non-null
at the time I make the AJAX request to fetch the comment preview, I simply call .abort()
on it so that I can cancel the previous AJAX request (truncated):
function showCommentPreview( commentMarkdown ) {
// If we have a non-null instance of the AbortController, it means that the
// previous AJAX request is still in-flight. By calling .abort() on it,
// the signal that we passed into the ApiClient (and fetch() within) will
// tell the fetch() API to abort the underlying HTTP request.
if ( previewAbortController ) {
previewAbortController.abort();
previewAbortController = null;
}
// Track a NEW instance of the AbortController for our next AJAX call to
// get the comment preview.
previewAbortController = new AbortController();
apiClient
.makeRequest({
url: "/index.cfm",
form: {
event: "api.blog.previewComment",
content: commentMarkdown
},
signal: previewAbortController.signal // <---- AbortSignal here.
})
.then(
( response ) => {
previewAbortController = null;
showPreviewContent( response.preview );
},
( error ) => {
// If we aborted the underlying HTTP request, ApiClient will
// set the isAbort flag to true. In this case, we can ignore
// these errors as they are not related to the rendering of the
// comment preview.
if ( error.status.isAbort ) {
return;
}
previewAbortController = null;
showPreviewError( error.data.message );
}
)
;
}
As you can see, prior to making the AJAX call, I'm calling .abort()
on the AbortController
if it exists. And, in my error / rejection handler, I'm just ignoring any errors where the .isAbort
flag (defined by my ApiClient
) is set to true
.
This code is actually more complicated than it needs to be. As you can see, I'm taking care to null-out the AbortController instance after I've used it. But, in reality, I don't have to do that. I could just leave the instance as-is, and call .abort()
on it multiple times without any issues; subsequent calls to the .abort()
method will be safely ignored.
Now, if I load my blog, set the Network Throttling to Slow 3G, and start typing a comment, we can see the HTTP requests for the comment preview getting canceled:
As you can see, once I trigger a new comment-preview request, any pending AJAX request is being aborted via the AbortController
.
At first, when transitioning from a simple .abort()
call on the jQuery.ajax()
response object to the AbortController
/ AbortSignal
/ fetch()
workflow, it felt like adding a lot of complexity. But, the more I play around with it (and the older I get), the more and more I appreciate a good separation of concerns. By decoupling the two concepts, you actually get simpler code in the long-run because everything is more explicit. And, the more explicit it is, the easier it is to maintain.
ApiClient
Current Implementation of My For completeness, here's my full ApiClient
code with the newly-added signal
configuration option. As part of this, I updated my error handling to include more robust information. In my first iteration, I was trying to hide away too much of the underlying HTTP request. But really, that's not what my ApiClient
should be doing - that's too high-level. My ApiClient
isn't supposed to hide the HTTP request, it's supposed to make it easier with baked-in, opinionated settings. As such, my normalized error structures now contain information about the errors that are occurring:
// Regular expression patterns for testing content-type response headers.
var RE_CONTENT_TYPE_JSON = new RegExp( "^application/(x-)?json", "i" );
var RE_CONTENT_TYPE_TEXT = new RegExp( "^text/", "i" );
// Static strings.
var UNEXPECTED_ERROR_MESSAGE = "An unexpected error occurred while processing your request.";
export class ApiClient {
/**
* I initialize the API client.
*/
constructor() {
// Nothing to do at this time. In the future, I could add things like base
// headers and other configuration defaults. But, I don't need any of that stuff
// at this time.
}
// ---
// PUBLIC METHODS.
// ---
/**
* I make the API request with the given configuration options.
*
* GUARANTEE: All errors produced by this method will have consistent structure, even
* if they are low-level networking errors. At a minimum, every Promise rejection will
* have the following properties:
*
* - data.type
* - data.message
* - status.code
* - status.text
* - status.isAbort
*/
async makeRequest( config ) {
// CAUTION: We want the entire contents of this method to be inside the try/catch
// so that we can guarantee that all errors occurring during this workflow will
// be caught and transformed into a consistent structure. NOTHING HERE SHOULD
// throw an error - but, bugs happen and people pass-in malformed parameters and
// I want the error-handling guarantees in place.
try {
// Extract options, with defaults, from config.
var contentType = ( config.contentType || null );
var headers = ( config.headers || Object.create( null ) );
var method = ( config.method || null );
var url = ( config.url || "" );
var params = ( config.params || Object.create( null ) );
var form = ( config.form || null );
var json = ( config.json || null );
var body = ( config.body || null );
var signal = ( config.signal || null );
// The fetch* variables are the values that we'll actually use to generate
// the fetch() call. We're going to assign these based on the configuration
// data that was passed-in.
var fetchHeaders = this.buildHeaders( headers );
var fetchMethod = null;
var fetchUrl = this.mergeParamsIntoUrl( url, params );
var fetchBody = null;
var fetchSignal = signal;
if ( form ) {
// NOTE: For form data posts, we want the browser to build the Content-
// Type for us so that it puts in both the "multipart/form-data" plus the
// correct, auto-generated field delimiter.
delete( fetchHeaders[ "content-type" ] );
// ColdFusion will only parse the form data if the method is POST.
fetchMethod = "post";
fetchBody = this.buildFormData( form );
} else if ( json ) {
fetchHeaders[ "content-type" ] = ( contentType || "application/x-json" );
fetchMethod = ( method || "post" );
fetchBody = JSON.stringify( json );
} else if ( body ) {
fetchHeaders[ "content-type" ] = ( contentType || "application/octet-stream" );
fetchMethod = ( method || "post" );
fetchBody = body;
} else {
fetchMethod = ( method || "get" );
}
var fetchRequest = new window.Request(
fetchUrl,
{
headers: fetchHeaders,
method: fetchMethod,
body: fetchBody,
signal: fetchSignal
}
);
var fetchResponse = await window.fetch( fetchRequest );
var data = await this.unwrapResponseData( fetchResponse );
if ( fetchResponse.ok ) {
return( data );
}
// The request came back with a non-2xx status code; but may still contain an
// error structure that is defined by our business domain.
return( Promise.reject( this.normalizeError( data, fetchRequest, fetchResponse ) ) );
} catch ( error ) {
// The request failed in a critical way; the content of this error will be
// entirely unpredictable.
return( Promise.reject( this.normalizeTransportError( error ) ) );
}
}
// ---
// PRIVATE METHODS.
// ---
/**
* I build a FormData instance from the given object.
*
* NOTE: At this time, only simple values (ie, no files) are supported.
*/
buildFormData( formFields ) {
var formData = new FormData();
Object.entries( formFields ).forEach(
( [ key, value ] ) => {
formData.append( key, value );
}
);
return( formData );
}
/**
* I transform the collection of HTTP headers into a like collection wherein the names
* of the headers have been lower-cased. This way, if we need to manipulate the
* collection prior to transport, we'll know what key-casing to use.
*/
buildHeaders( headers ) {
var lowercaseHeaders = Object.create( null );
Object.entries( headers ).forEach(
( [ key, value ] ) => {
lowercaseHeaders[ key.toLowerCase() ] = value;
}
);
return( lowercaseHeaders );
}
/**
* I build a query string (less the leading "?") from the given params.
*
* NOTE: At this time, there is no special handling of array-based values.
*/
buildQueryString( params ) {
var queryString = Object.entries( params )
.map(
( [ key, value ] ) => {
if ( value === true ) {
return( encodeURIComponent( key ) );
}
return( encodeURIComponent( key ) + "=" + encodeURIComponent( value ) );
}
)
.join( "&" )
;
return( queryString );
}
/**
* I merged the given params into the given URL. This is done by parsing the URL,
* extracting the URL-based params, merging them with the given params, and then
* rebuilding the URL with the merged params.
*
* NOTE: The given params take precedence in the case of a name-conflict.
*/
mergeParamsIntoUrl( url, params ) {
// Split on fragment segments.
var hashParts = url.split( "#", 2 );
var preHash = hashParts[ 0 ];
var fragment = ( hashParts[ 1 ] || "" );
// Split on search segments.
var urlParts = preHash.split( "?", 2 );
var scriptName = urlParts[ 0 ];
// When merging the url-params and the additional params, the additional params
// take precedence (meaning, they will overwrite url-based params).
var urlParams = this.parseQueryString( urlParts[ 1 ] || "" );
var mergedParams = Object.assign( urlParams, params );
var queryString = this.buildQueryString( mergedParams );
var results = [ scriptName ];
if ( queryString ) {
results.push( "?", queryString );
}
if ( fragment ) {
results.push( "#", fragment );
}
return( results.join( "" ) );
}
/**
* At a minimum, we want every error to have the following properties:
*
* - data.type
* - data.message
* - status.code
* - status.text
* - status.isAbort
*
* These are the keys that the calling context will depend on; and, are the minimum
* keys that the server is expected to return when it throws domain errors.
*/
normalizeError( data, fetchRequest, fetchResponse ) {
var error = {
data: {
type: "ServerError",
message: UNEXPECTED_ERROR_MESSAGE
},
status: {
code: fetchResponse.status,
text: fetchResponse.statusText,
isAbort: false
},
// The following data is being provided to make debugging AJAX errors easier.
request: fetchRequest,
response: fetchResponse
};
// If the error data is an Object (which it should be if the server responded
// with a domain-based error), then it should have "type" and "message"
// properties within it. That said, just because this isn't a transport error, it
// doesn't mean that this error is actually being returned by our application.
if (
( typeof( data?.type ) === "string" ) &&
( typeof( data?.message ) === "string" )
) {
Object.assign( error.data, data );
// If the error data has any other shape, it means that an unexpected error
// occurred on the server (or somewhere in transit). Let's pass that raw error
// through as the rootCause, using the default error structure.
} else {
error.data.rootCause = data;
}
return( error );
}
/**
* If our request never makes it to the server (or the round-trip is interrupted
* somehow), we still want the error response to have a consistent structure with the
* application errors returned by the server. At a minimum, we want every error to
* have the following properties:
*
* - data.type
* - data.message
* - status.code
* - status.text
* - status.isAbort
*/
normalizeTransportError( transportError ) {
return({
data: {
type: "TransportError",
message: UNEXPECTED_ERROR_MESSAGE,
rootCause: transportError
},
status: {
code: 0,
text: "Unknown",
isAbort: ( transportError.name === "AbortError" )
}
});
}
/**
* I parse the given query string into an object.
*
* NOTE: This method assumes that the leading "?" has already been removed.
*/
parseQueryString( queryString ) {
var params = Object.create( null );
for ( var pair of queryString.split( "&" ) ) {
var parts = pair.split( "=", 2 );
var key = decodeURIComponent( parts[ 0 ] );
// CAUTION: If there is no value in the query string pair, we want to use a
// literal TRUE value since this literal value will be treated differently
// when subsequently serializing the params back into a query string.
var value = ( parts[ 1 ] )
? decodeURIComponent( parts[ 1 ] )
: true
;
params[ key ] = value;
}
return( params );
}
/**
* I unwrap the response payload from the given response based on the reported
* content-type.
*/
async unwrapResponseData( response ) {
var contentType = response.headers.has( "content-type" )
? response.headers.get( "content-type" )
: ""
;
if ( RE_CONTENT_TYPE_JSON.test( contentType ) ) {
return( response.json() );
} else if ( RE_CONTENT_TYPE_TEXT.test( contentType ) ) {
return( response.text() );
} else {
return( response.blob() );
}
}
}
Updating my old blog code is so much fun!
Want to use code from this post? Check out the license.
Reader Comments
After writing this, I got think about what else I can abort using the
AbortController
. And, it occurred to me that managing and debouncing asetTimeout()
call might be interesting to look at:www.bennadel.com/blog/4195-using-abortcontroller-to-debounce-settimeout-calls-in-javascript.htm
This is not an approach that I would recommend for all instances; but, I think it might unlock some interesting interactions with other abortable workflows.
Is AbortController compatible with Angular's http service?
@Elliott,
That's an interesting question. I don't think it is - or at least I don't see anything in the documentation. That said, since Angular (modern) uses RxJS and Observable streams for all of its HTTP mechanics, you could always create something that lists to the
AbortSignal
and calls.unsubscribe()
under the hood, which should abort the underlying HTTP request.This is why I always suggest that you create a custom API client that wraps around the HTTP service. This gives you an opportunity to add a lot of "opinionated workflows" around HTTP management.
Post A Comment — ❤️ I'd Love To Hear From You! ❤️
Post a Comment →