Last active
February 8, 2022 10:52
-
-
Save dmorosinotto/d0d38d94066e43f10f8e46c41c6b56f5 to your computer and use it in GitHub Desktop.
ApiClient class using fetch + retry backoff logic
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
//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