Skip to content

Instantly share code, notes, and snippets.

@oculus42
Last active May 17, 2023 00:25
Show Gist options
  • Save oculus42/0070afc025dabfa1875c4bf439a64060 to your computer and use it in GitHub Desktop.
Save oculus42/0070afc025dabfa1875c4bf439a64060 to your computer and use it in GitHub Desktop.
Allow multiple requests to share a response, even if the response is from a request that has not been made yet.
/**
* requestLatest allows multiple requests to share resolutions from the past or future.
* This allows multiple requestors to receive the most up-to-date response without
* causing promise rejections through canceled requests.
* This can reduce the complexity of consumer logic by eliminating many checks for
* cancellation or stale data.
*
* We start with makeRequestContainer to share responses to a single endpoint.
* This stores a last request (currentPromise) to avoid making identical requests. The parameter
* `isSamePayload` tells us if we can re-use currentPromise or should make a new request.
*
* Calling the request container returns a Promise.race(). If `isSamePayload` indicates
* the payload matches the previous request (currentPromise) it will be returned. Otherwise,
* a new call is made. The race also contains a Deferred - an externally controlled promise.
* This allows a subsequent call to silently cancel an incomplete earlier request while still
* fulfilling the original Promise.race() using an updated request/response.
*
* This lets us resolve a promise with a request made after the function call - "from the future".
*/
import axios from 'axios';
// Expose resolve/reject for completing a promise later.
export const makeDeferred = () => {
let resolve;
let reject;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return {
promise,
resolve,
reject,
};
};
export const createRequestState = () => ({
futurePromise: makeDeferred(),
previousPayload: {},
currentPromise: Promise.resolve(),
currentAbortController: undefined,
});
/**
* Leave promise hanging if it was canceled.
* This allows our Promise.race() to work.
*/
export const cancelSilently = (e) => {
if (e.name === 'CanceledError') {
return new Promise(() => {});
}
return Promise.reject(e);
};
export const makeRequestContainer = (url, method, isSamePayload) => {
// Generate a common object to use as the default context for the WeakMap if none is passed.
const defaultContext = {};
const requestStateMap = new WeakMap();
const makeRequest = (payload, context = defaultContext) => {
if (!requestStateMap.has(context)) {
requestStateMap.set(context, createRequestState());
}
const state = requestStateMap.get(context);
if (isSamePayload(payload, state.previousPayload)) {
return Promise.race([state.currentPromise, state.futurePromise.promise]);
}
// Abort last if exists (and is different payload)
state.currentAbortController?.abort();
state.currentAbortController = new AbortController();
const lastFuturePromise = state.futurePromise;
// Create a new next for my future
state.futurePromise = makeDeferred();
state.previousPayload = payload;
// Wrap axios in a promise to avoid double firing the reject path
// This happens with Abort + Network Error
state.currentPromise = new Promise((res, rej) => axios[method](url, payload, {
signal: state.currentAbortController.signal,
}).then(res, rej)).catch(cancelSilently);
const race = Promise.race([state.currentPromise, state.futurePromise.promise]);
// Resolve "last future" with the next race to allow additional requests to propagate.
race.then((result) => {
lastFuturePromise.resolve(result);
return result;
}, (error) => {
lastFuturePromise.reject(error);
// Pass the error forward
return Promise.reject(error);
});
return race;
};
return makeRequest;
};
export default {
cancelSilently,
createRequestState,
makeDeferred,
makeRequestContainer,
};
/* eslint-disable no-param-reassign */
import requestLatest from 'common/util/requestLatest';
import { isEqual } from 'lodash';
import axios from 'axios';
// TODO - use axios mock https://gist.github.com/oculus42/c5201a4bc5939a7dc68abe69e580e46d
describe('requestContainer', () => {
let getData;
beforeEach(() => {
getData = requestLatest.makeContainer('/data', 'post', isEqual);
})
it('should make one request if payload is the same', async () => {
const payload = {
test: 2,
};
// Start the requests immediately
const req1 = getData(payload);
const req2 = getData(payload);
// Await as a group
const res1 = await req1;
const res2 = await req2;
expect(res1.data.test).toEqual(2);
expect(res1.data.test).toEqual(res2.data.test);
expect(res1.request.requestNumber).toEqual(res2.request.requestNumber);
});
it('should make one request if payload is the same in multiple', async () => {
const payload = {
test: 3,
};
// Start the requests immediately
const req1 = getData(payload);
const req2 = getData(payload);
const req3 = getData(payload);
// Await as a group
const res1 = await req1;
const res2 = await req2;
const res3 = await req3;
expect(res1.data.test).toEqual(3);
expect(res1.data.test).toEqual(res2.data.test);
expect(res1.data.test).toEqual(res3.data.test);
expect(res1.request.requestNumber).toEqual(res2.request.requestNumber);
expect(res1.request.requestNumber).toEqual(res3.request.requestNumber);
});
it('should provide the last response if there are new payloads', async () => {
// Start the requests immediately
const req1 = getData({ test: 1 });
const req2 = getData({ test: 2 });
const req3 = getData({ test: 3 });
// Await as a group
const res1 = await req1;
const res2 = await req2;
const res3 = await req3;
expect(res1.data.test).toEqual(3);
expect(res1.data.test).toEqual(res2.data.test);
expect(res1.data.test).toEqual(res3.data.test);
expect(res1.request.requestNumber).toEqual(res2.request.requestNumber);
expect(res1.request.requestNumber).toEqual(res3.request.requestNumber);
});
it('should handle a mix of payloads', async () => {
// Reset our mock request counter
axios.requestCount = 1;
// One, then two requests for the same payload. Should return "request 2"
const req1 = getData({ test: 1 });
const req2 = getData({ test: 2 });
const req3 = getData({ test: 2 });
// Await as a group
const res1 = await req1;
const res2 = await req2;
const res3 = await req3;
expect(res1.data.test).toEqual(2);
expect(res1.data.test).toEqual(res2.data.test);
expect(res1.data.test).toEqual(res3.data.test);
// All should resolve with the 2nd request.
expect(res1.request.requestNumber).toEqual(2);
expect(res1.request.requestNumber).toEqual(res2.request.requestNumber);
expect(res1.request.requestNumber).toEqual(res3.request.requestNumber);
// Ehere should be no third request
expect(axios.requestCount).toBe(3);
});
it('should provide the same response for identical payloads, even after a delay', async () => {
const payload = {
test: '2, slowly',
};
const req1 = getData(payload);
const res1 = await req1;
const req2 = getData(payload);
const res2 = await req2;
expect(res1.data.test).toEqual(payload.test);
expect(res1.data.test).toEqual(res2.data.test);
expect(res1.request.requestNumber).toEqual(res2.request.requestNumber);
});
it('should expose HTTP rejections as expected', () => {
jest.useFakeTimers();
expect.assertions(1);
// Create a request that will fail
const req1 = getData({
test: 1,
shouldResolve: false,
result: {
foo: 7,
},
});
// Wait for the failure to occur
setTimeout(() => {
req1.catch(err => expect(err).toBeInstanceOf(Error));
}, 20);
jest.runAllTimers();
jest.useRealTimers();
});
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment