unjs / ofetch

😱 A better fetch API. Works on node, browser and workers.
MIT License
4.01k stars 126 forks source link

When using interceptors, how to wait some action before continue to another requests? #441

Closed enzonun closed 2 days ago

enzonun commented 3 weeks ago

Describe the feature

Im using ofetch interceptor onResponse to know if the response status code is 401 then I call refresh token, but I have a problem when multiple calls happen at the same time, I want to wait the refresh token call finish in the first request and then continue with the others. This is the code I'm using, the same is mentioned in another closed issue https://github.com/unjs/ofetch/issues/79

import type { FetchRequest, FetchOptions, FetchResponse } from 'ofetch';
import { ofetch } from 'ofetch';
const fetcher = ofetch.create({
  baseURL: process.env.API_URL + '/api',
  async onRequest({ options }) {
    const accessToken = localStorage.getItem('accessToken');
    const language = localStorage.getItem('language');
    if (accessToken) {
      options.headers = {
        ...options.headers,
        Authorization: `Bearer ${accessToken}`,
      };
    }
    if (language) {
      options.headers = {
        ...options.headers,
        'Accept-Language': language,
      };
    }
  },
  async onResponse({ response }) {
    if (response.status === 401 && localStorage.getItem('refreshToken')) {
      const { accessToken } = await ofetch('/auth/token', {
        baseURL: process.env.API_URL + '/api',
        method: 'POST',
        body: {
          accessToken: localStorage.getItem('accessToken'),
          refreshToken: localStorage.getItem('refreshToken'),
        },
      });
      localStorage.setItem('accessToken', accessToken);
    }
  },
});
export default async <T>(request: FetchRequest, options?: FetchOptions) => {
  try {
    const response = await fetcher.raw(request, options);
    return response as FetchResponse<T>;
  } catch (error: any) {
    if (error.response?.status === 401 && localStorage.getItem('refreshToken')) {
      const response = await fetcher.raw(request, options);
      return response as FetchResponse<T>;
    }
    return error.response as FetchResponse<T>;
  }
};

Additional information

rafifn commented 2 weeks ago

this is how i handle it in nuxt

import type { FetchOptions } from 'ofetch'

export default defineNuxtPlugin(() => {
  const authToken = useCookie('_auth_token')
  const refreshToken = useCookie('_refresh_token')

  const buildContextRetry = (options: FetchOptions, token: string) => {
    const headers = new Headers(options.headers)
    headers.set('Authorization', `Bearer ${token}`)
    return {
      baseURL: options.baseURL,
      body: options.body,
      headers,
      method: options.method,
      params: options.params,
      query: options.query,
      responseType: options.responseType,
      ignoreResponseError: options.ignoreResponseError,
      parseResponse: options.parseResponse,
      duplex: options.duplex,
      timeout: options.timeout,
    }
  }

  const api = $fetch.create({
    retry: 3,
    onRequest({ _request, options, _error }) {
      refreshCookie('_auth_token')
      if (authToken.value) {
        const headers = (options.headers ||= {})
        if (Array.isArray(headers)) {
          headers.push(['Authorization', `Bearer ${authToken.value}`])
        }
        else if (headers instanceof Headers) {
          headers.set('Authorization', `Bearer ${authToken.value}`)
        }
        else {
          headers.Authorization = `Bearer ${authToken.value}`
        }
      }
    },
    onResponse(context) {
      if (context.response.status === 401) {
        refreshCookie('_auth_token')
        return new Promise((resolve, reject) => {
          $fetch('/api/auth/refresh-token', {
            method: 'POST',
            headers: {
              authorization: `Bearer ${refreshToken.value}`,
            },
          })
            .then(async ({ data }) => {
              const accessTokenExpiredAt = new Date(data.accessTokenExpiredAt)
              const refreshTokenExpiredAt = new Date(data.refreshTokenExpiredAt)
              useCookie('_auth_token', { expires: accessTokenExpiredAt, default: () => data.accessToken })
              useCookie('_refresh_token', { expires: refreshTokenExpiredAt, default: () => data.refreshToken })
              const retryOptions = buildContextRetry(context.options, data.accessToken)
              refreshCookie('_auth_token')
              refreshCookie('_refresh_token')
              await resolve($fetch(context.request, {
                ...retryOptions,
                onResponse(ctx) {
                  Object.assign(context, ctx)
                },
              }))
            })
            .catch((error) => {
              authToken.value = undefined
              refreshToken.value = undefined
              reject(error)
              return navigateTo('/login')
            })
        })
      }
    },
  })

  // Expose to useNuxtApp().$api
  return {
    provide: {
      api,
    },
  }
})
enzonun commented 2 weeks ago

Nice! I ended up using it like this


let refreshingToken = false
let requestsQueue: { resolve: any, reject: any }[] = []

const processQueue = (error: any, token: string | null = null) => {
  requestsQueue.forEach((prom) => {
    if (error) {
      prom.reject(error)
    } else {
      prom.resolve(token)
    }
  })

  requestsQueue = []
}
const apiFetcher = ofetch.create({
  baseURL: import.meta.env.VITE_API_BASE_URL + '/api',
  timeout: 30000,
  retry: false,
  credentials: 'include',
  async onResponse(ctx) {
    if (ctx.response.status === 401) {
      if (refreshingToken) {
        return new Promise((resolve, reject) => {
          requestsQueue.push({ resolve, reject })
        })
          .then((resp) => {
            if (resp) {
              ofetch(ctx.request, ctx.options)
            }
          })
          .catch((err) => {
            return Promise.reject(err)
          })
      }
      refreshingToken = true
      const retry = new Promise((resolve, reject) => {
        ofetch.raw<string>('/auth/Refresh-Token', {
          baseURL: import.meta.env.VITE_API_BASE_URL + '/api',
          method: 'POST',
          credentials: 'include'
        }).then((resp) => {
          processQueue(null, resp._data)
          resolve(this)
        }).catch(err => {
          processQueue(err, null)
          const authStore = useAuthStore()
          authStore.signOut()
          reject(this)
        }).finally(() => {
          refreshingToken = false
          // resolve(this)
        })
      })
      await retry
    }
  }

})

export default apiFetcher
kardeepak77 commented 2 weeks ago

@enzonun Thanks for posting this code, super helpful! Question : Do you need to resend original request for which you received accessToken expired? And had to trigger refreshToken flow? In other words - requestsQueue.push() is called above only for subsequest requests but not for original request

enzonun commented 1 week ago

@enzonun Thanks for posting this code, super helpful! Question : Do you need to resend original request for which you received accessToken expired? And had to trigger refreshToken flow? In other words - requestsQueue.push() is called above only for subsequest requests but not for original request

Yes but because I have a wrapper of the apiFetcher, that does the original request when it fails, this way

import apiFetcher from "./apiFetcher"

const apiFetchRaw = async <T = any>(request: FetchRequest, options?: FetchOptions) => {
  try {
    const response = await apiFetcher.raw(request, options)
    return response as FetchResponse<T>
  } catch (error: any) {
    if (error.response?.status === 401) {
      const response = await apiFetcher.raw(request, options)
      return response as FetchResponse<T>
    }
    return error.response as FetchResponse<T>
  }
}

export default apiFetchRaw

I did it in that way because I wanted a version of ofetch.raw, and another wrapper with ofetch, but maybe it can be done in another way

My app is running currently in SSR:false so IDK if it will have some problems on SSR, and I had to add the ofetch explicitly to use the typescript types, so that's another thing that I have to figure it out, maybe I can use nuxt $fetch directly,

enzonun commented 2 days ago

I'm closing this because we already figure it out how to do it