How can I handle simultaneous requests for authenticated API, pause all but first while it fetches new access token?

Viewed 107

For this question, I'm going to state that I'm using JWT tokens. The complexity and handling of these tokens are working great. There are refresh tokens and access tokens, and for this question, we only need to worry about how and when to send the request for retrieving a new access token.

My application (NextJS) is requesting to retrieve data. The endpoint requires an authentication token. This token, along with its expiry date, is stored in memory (React state, I've built a custom hook (useAuth), and its state is available globally in my app.

When the request gets made, we check the expiry, and if the token is deemed invalid (by the expiration date being in the past), we request a new one before proceeding with our fetch request.

If the endpoint gets tried without a valid token, we get a return value of false.

This procedure works well when there is one fetch request, and the issue I have (and therefore this question) is if I have multiple fetch requests happening almost simultaneously. While it will work, each request wants to retrieve a new access token, and we end up with too many requests for an access token when one would suffice.

Ideally, I would like the first request to retrieve the access token (if needed) and the other fetches to wait until a new access token gets granted before proceeding.

Time for some code examples

For the fetch requests I'm using useSWR (https://swr.vercel.app/). Here is one example.

const auth = useAuth();

// Example of ONE fetch (multiply this and use different endpoints, variable deconstruction etc)

const {
        data: venue,
        error: venueError,
        mutate: venueMutate,
    } = useSWR(auth?.isLoggedIn ? ['/api/venues/getVenue', venueUUID] : null, (url, venueUUID) =>
        auth.fetchWithUUID(url, venueUUID)
    );

You do not need to concern yourself with variables such as auth?.isLoggedIn or VenueUUID; know that this (these) request is (are) happening.

The function - auth.fetchWithUUID() - looks like this;

const fetchWithUUID = async (url, uuid, payload = {}) => {
    if (!ref?.current?.accessToken?.expiry || ref?.current?.accessToken?.expiry < +new Date()) {
        console.log('new call');
        await retrieveAccessToken('/api/getAccessToken', true);

        let apiResponse = await fetch_retry(url, uuid, payload, 3);
        return apiResponse?.res;
    } else {
        let apiResponse = await fetch_retry(url, uuid, payload, 3);
        return apiResponse?.res;
    }
};

ref?.current allows us to access current state (https://www.npmjs.com/package/react-usestateref)

The function - retrieveAccessToken() - looks like this;

const retrieveAccessToken = async (url, override = false) => {
    if (!isLoggedIn || override === true) {
        let apiResponse = await fetch(url, {
            method: 'GET',
        }).then((res) => {
            if (res.status >= 300 && res.status != 401) throw new Error('API Client error');
            return res.json();
        });

        console.log('setting access token');

        setUser(apiResponse?.user);

        return isLoggedInRef.current === true ? true : false;
    }

    return null;
};

and lastly, fetch_retry() is this;

const fetch_retry = async (url, uuid, payload, n) => {
    try {
        let thisRes = await fetch(url, {
            method: 'POST',
            headers: {
                'Content-Type': 'application/json',
                Authorization: `Bearer ${ref?.current?.accessToken?.token}`,
            },
            body: JSON.stringify({
                UUID: uuid,
                ...payload,
            }),
        }).then((res) => {
            if (res.status >= 300 && res.status != 401) throw new Error('API Client error');
            return res.json();
        });

        if (thisRes === false) {
            if (n === 1) return false;

            return await fetch_retry(url, uuid, payload, n - 1);
        } else {
            return thisRes;
        }
    } catch (err) {
        if (n === 1) throw err;
        return await fetch_retry(url, uuid, payload, n - 1);
    }
};

In this current configuration, I am getting all the results from the fetches as intended. Other endpoints behave the same way; they fetch different data. But they all request access tokens, and they are happening milliseconds apart.

expiry 1632450789126
date now - 1632450800173
new call

expiry 1632450789126
date now 1632450800177
new call

expiry 1632450789126
date now 1632450800180
new call

expiry 1632450789126
date now 1632450800182
new call

So, who can put me on a new path? :) Do I need to create a queuing system?

1 Answers

You can use semaphore something like this

const tokenRequest = useRef(false);
function checkTokenRequest() {
    return new Promise(function(resolve, reject) {
        (function waitForTokenRequest() {
            if (!tokenRequest.current) return resolve();
            setTimeout(waitForTokenRequest, 30);
        })();
    });
}

const retrieveAccessToken = async (url, override = false) => {
    if (!isLoggedIn || override === true) {
        if (tokenRequest.current) {
            await checkTokenRequest();
        } else {
            tokenRequest.current = true;

            let apiResponse = await fetch(url, {
                method: 'GET'
            }).then((res) => {
                if (res.status >= 300 && res.status != 401) throw new Error('API Client error');
                return res.json();
            });

            console.log('setting access token');

            setUser(apiResponse?.user);

            tokenRequest.current = false;
        }

        return isLoggedInRef.current === true ? true : false;
    }

    return null;
};

Thus, no more than one token request will be launched at the same time

Minimal example here https://codesandbox.io/s/white-paper-lp7vg

More example of the wait function can be found here

Related