ofetch icon indicating copy to clipboard operation
ofetch copied to clipboard

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

Open enzonun opened this issue 1 year ago • 4 comments

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

  • [ ] Would you be willing to help implement this feature?

enzonun avatar Sep 05 '24 22:09 enzonun

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,
    },
  }
})

rafifn avatar Sep 13 '24 14:09 rafifn

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

enzonun avatar Sep 15 '24 16:09 enzonun

@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

kardeepak77 avatar Sep 16 '24 00:09 kardeepak77

@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<T>, 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 avatar Sep 24 '24 15:09 enzonun

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

enzonun avatar Sep 29 '24 02:09 enzonun

太好了!我最终是这样使用的

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

Can this method be used in combination with useAsyncData during SSR?

I've been trying for some time, but I still can't figure out how to refresh token during SSR. I was wondering if you have any new solutions

immortal521 avatar Sep 27 '25 08:09 immortal521