Skip to content

Instantly share code, notes, and snippets.

@dmorosinotto
Last active February 8, 2022 10:52
Show Gist options
  • Save dmorosinotto/d0d38d94066e43f10f8e46c41c6b56f5 to your computer and use it in GitHub Desktop.
Save dmorosinotto/d0d38d94066e43f10f8e46c41c6b56f5 to your computer and use it in GitHub Desktop.
ApiClient class using fetch + retry backoff logic
//ORIGINAL CODE BY @BenNadel READ ARTICLE: https://www.bennadel.com/blog/4200-using-fetch-abortsignal-and-settimeout-to-apply-retry-mechanics-in-javascript.htm
// Regular expression patterns for testing content-type response headers.
const RE_CONTENT_TYPE_JSON = new RegExp("^application/(x-)?json", "i");
const RE_CONTENT_TYPE_TEXT = new RegExp("^text/", "i");
// Static strings.
const UNEXPECTED_ERROR_MESSAGE = "An unexpected error occurred while processing your request.";
type TKeyValue<T> = Record<string, T>;
type TMethods = "GET" | "get" | "POST" | "post" | "PUT" | "put" | "DELETE" | "delete";
interface IConfig {
contentType?: string;
headers?: TKeyValue<string> | unknown;
method?: TMethods | string;
url: string;
params?: TKeyValue<string | number | boolean> | unknown;
form?: TKeyValue<string | Blob> | unknown;
json?: any;
body?: unknown;
signal?: AbortSignal;
}
export class ApiClient {
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
*/
public async makeRequest(config: IConfig) {
// 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.
const contentType = config.contentType || null;
const headers = config.headers || Object.create(null);
const method = config.method || null;
const url = config.url || "";
const params = config.params || Object.create(null);
const form = config.form || null;
const json = config.json || null;
const body = config.body || null;
const 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.
const fetchHeaders = this.buildHeaders(headers);
let fetchMethod: TMethods | string;
const fetchUrl = this.mergeParamsIntoUrl(url, params);
let fetchBody = null;
const 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/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";
}
let fetchRequest = new window.Request(fetchUrl, {
headers: fetchHeaders,
method: fetchMethod,
body: fetchBody,
signal: fetchSignal,
});
let fetchResponse = await window.fetch(fetchRequest);
let 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));
}
}
/**
* I make the API request with the given configuration options. If the request rejects
* with a retryable error, an "exponential" back-off will be applied to retry the
* request several times before giving up.
*
* GUARANTEE: This method has all the same guarantees as the makeRequest() method - it
* is nothing more than a proxy in front for the underlying result and error values.
*/
public async makeRequestWithRetry(config: IConfig) {
// Rather than relying on the maths to do back-off calculations, this collection
// provides an explicit set of back-off values (in milliseconds). This collection
// also doubles as the number of attempts that we should execute against the
// underlying makeRequest() method.
// --
// NOTE: Some randomness will be applied to these values as execution time.
const backoffDurations = [
200,
700,
1000,
2000,
4000,
8000,
16000,
0, // Indicates that the last timeout should be recorded as an error.
];
// CAUTION: We must use a FOR-OF loop (not a .forEach() loop) so that we can
// leverage await inside the loop and make the overall workflow block-and-wait.
for (let retryDelay of backoffDurations) {
try {
return await this.makeRequest(config);
} catch (error) {
// NOTE: In some cases, we would want to inspect the HTTP METHOD in order
// to see if it is retryable (and perhaps only allow GET requests to be
// retried). However, since this API Client has a separate method designed
// specifically for retries, we are going to assume that the calling
// context understands when it IS OK and IS NOT OK to retry. Therefore, we
// are only going to look at the returned error as a means to to determine
// if retry is the best next step.
if (!retryDelay || !this.isRetryableError(error)) {
throw error;
}
console.warn(`API request failed, retrying in ${retryDelay}ms.`);
await this.blockAndWait(this.applyJitter(retryDelay), config.signal);
}
}
}
// ---
// PRIVATE METHODS.
// ---
/**
* I apply a +/- 20% offset to the current value (a small attempt to prevent the
* stampeding herd problem).
*/
private applyJitter(value: number) {
// Generate a +/- 20% delta from the original value.
const percentJitter = (20 - Math.floor(Math.random() * 40)) / 100;
const jitter = value * percentJitter;
return Math.floor(value + jitter);
}
/**
* I return a Promise that is resolved after the given timeout. The internal timer can
* be canceled with an optional AbortSignal. If the internal timer is aborted, the
* returned Promise will never resolve.
*/
private blockAndWait(durationInMilliseconds: number, signal?: AbortSignal) {
let promise = new Promise((resolve) => {
// When the calling context triggers an abort, we need to listen for it so
// that we can turn around and clear the internal timer.
// --
// NOTE: We're creating a proxy callback for our resolve function in order
// to remove this event-listener once the timer executes. This way, our
// event-handler never gets invoked if there's nothing for it to actually
// do. Also note that the "abort" event will only ever get emitted once,
// regardless of how many times the calling context tries to invoke
// .abort() on its AbortController.
signal?.addEventListener("abort", handleAbort);
// Setup our internal timer that we can clear-on-abort.
let internalTimer = setTimeout(internalResolve, durationInMilliseconds);
// -- Internal methods. -- //
function internalResolve() {
signal?.removeEventListener("abort", handleAbort);
resolve(durationInMilliseconds);
}
function handleAbort() {
clearTimeout(internalTimer);
}
});
return promise;
}
/**
* I build a FormData instance from the given object.
*
* NOTE: At this time, only simple values (ie, no files) are supported.
*/
private buildFormData(formFields: TKeyValue<string | Blob> | unknown) {
let 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.
*/
private buildHeaders(headers: TKeyValue<string>) {
let lowercaseHeaders: TKeyValue<string> = 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.
*/
private buildQueryString(params: TKeyValue<string | number | boolean>) {
let queryString = Object.entries(params)
.map(([key, value]) => {
if (value === true) {
return encodeURIComponent(key);
}
return encodeURIComponent(key) + "=" + encodeURIComponent(value);
})
.join("&");
return queryString;
}
/**
* I try to determine if the given request-error object indicates that the original
* request can be retried.
*/
private isRetryableError(error: { status: { code: number; isAbort: Boolean } } | any) {
// If the error was triggered by an explicit Abort, we don't want to retry it -
// it was terminated early on purpose by the calling context.
if (error.status.isAbort) {
return false;
}
// Short-hand for the HTTP status code of the error response.
const code = error.status.code;
return (
code === -1 || // Various network failures.
code === 0 || // Various network failures.
code === 408 || // Request timeout.
code === 429 || // Too many requests (ie, rate limiting).
code >= 500 // Basically all unexpected server errors.
);
}
/**
* 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.
*/
private mergeParamsIntoUrl(url: string, params: TKeyValue<string | number | boolean>) {
// Split on fragment segments.
const hashParts = url.split("#", 2);
const preHash = hashParts[0];
const fragment = hashParts[1] || "";
// Split on search segments.
const urlParts = preHash.split("?", 2);
const apiName = urlParts[0];
// When merging the url-params and the additional params, the additional params
// take precedence (meaning, they will overwrite url-based params).
const urlParams = this.parseQueryString(urlParts[1] || "");
const mergedParams = Object.assign(urlParams, params);
const queryString = this.buildQueryString(mergedParams);
const results = [apiName];
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.
*/
private normalizeError(data: any, fetchRequest: Request, fetchResponse: Response) {
let error = {
data: {
type: "ServerError",
message: UNEXPECTED_ERROR_MESSAGE,
error: null,
},
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 "error" and "type" or "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 (!!data?.error && (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.error = 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
*/
private normalizeTransportError(transportError: Error|any) {
return {
data: {
type: "TransportError",
message: UNEXPECTED_ERROR_MESSAGE,
error: 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.
*/
private parseQueryString(queryString: string): TKeyValue<string | number | boolean> {
let params: TKeyValue<string | number | boolean> = Object.create(null);
for (let pair of queryString.split("&")) {
const parts = pair.split("=", 2);
const 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.
const 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.
*/
private async unwrapResponseData(response: Response) {
const 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();
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment