Last active
May 17, 2023 00:25
-
-
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.
This file contains 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
/** | |
* 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, | |
}; |
This file contains 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
/* 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