Axios插件-请求重试

379 阅读4分钟

本文将使用TS开发一个Axios插件,用来解决在某些情况下接口请求错误时,需要自动重试的问题。

功能

  1. 接口错误时,能够自动重试并返回最终结果;
  2. 可自定义最大重试次数、重试间隔时间;
  3. 可自定义何时重试,即自定义何为接口错误;
  4. 提供开始重试之前执行的钩子函数,可执行一些自定义逻辑;
  5. 提供重试失败回调方法;
  6. 无缝集成到axios中。

开发

核心实现

以下代码实现了一个最基本的请求重试插件,仅在接口的http状态错误时,才自动重试。

import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios'

export interface IOptions {
  maxRetryCount: number
  retryDelay: number
  request: (config: InternalAxiosRequestConfig) => Promise<AxiosResponse>
}
// 将最大重试次数与重试间隔参数改为可选参数
export type IConfig = Partial<IOptions> & Pick<IOptions, 'request'>

export class AxiosRetryPlugin {
  private options: IOptions
  private histories: Map<string, number>

  constructor(options: IConfig) {
    this.options = {
      maxRetryCount: options.maxRetryCount ?? 3,
      retryDelay: options.retryDelay ?? 1000,
      request: options.request
    }

    this.histories = new Map()
  }

  static getDataType(obj: any) {
    let res = Object.prototype.toString.call(obj).split(' ')[1]
    res = res.substring(0, res.length - 1).toLowerCase()
    return res
  }

  static generateRequestKey(config: InternalAxiosRequestConfig): string {
    const { method, url, data, params } = config
    let key = `${method}-${url}`
    try {
      switch (AxiosRetryPlugin.getDataType(data)) {
        case 'object':
          key += `-${JSON.stringify(data)}`
          break
        case 'formdata':
          for (const [k, v] of data.entries()) {
            if (v instanceof Blob) {
              continue
            }
            key += `-${k}-${v}`
          }
          break
        default:
          break
      }

      if (AxiosRetryPlugin.getDataType(params) === 'object') {
        key += `-${JSON.stringify(params)}`
      }
    } catch (e) {/* empty */}
    return key
  }

  private fetch(config: InternalAxiosRequestConfig) {
    return new Promise<AxiosResponse>((resolve, reject) => {
      if (!this.options.retryDelay || this.options.retryDelay < 0) {
        this.options.request(config).then(resolve).catch(reject)
        return
      }

      setTimeout(() => {
        this.options.request(config).then(resolve).catch(reject)
      }, this.options.retryDelay)
    })
  }

  private retryRequest(error: AxiosError) {
    let config: InternalAxiosRequestConfig
    if (!error.config) {
      return Promise.reject(error)
    }
    config = error.config

    const key = AxiosRetryPlugin.generateRequestKey(config)
    const retryCount = this.histories.get(key) || 0
    if (retryCount >= this.options.maxRetryCount) {
      this.histories.delete(key)
      return Promise.reject(error)
    }

    this.histories.set(key, retryCount + 1)
    return this.fetch(config)
  }

  responseInterceptorRejected(error: AxiosError) {
    return this.retryRequest(error)
  }
}

// 为什么用bind绑定this?因为注册axios拦截器时,axios会改变this指向
export default function createAxiosRetryPlugin(config: IConfig) {
  const instance = new AxiosRetryPlugin(config)

  return {
    responseInterceptorRejected: instance.responseInterceptorRejected.bind(instance)
  }
}

使用示例:

import axios from 'axios'
import createAxiosRetryPlugin from '..'

const retryPlugin = createAxiosRetryPlugin({ request: axios.request })
axios.interceptors.response.use(undefined, retryPlugin.responseInterceptorRejected)

仅需创建一个插件实例,然后将实例的响应失败拦截器方法注册到axios的响应拦截器中即可。

高级功能(完整代码)

可能有些时候我们需要,自己决定何时自动重试,例如:根据响应数据的code决定,而不是http状态。
也可能我们需要在重试之前,需要执行一些自定义的逻辑代码。
那我们接下来就来添加这些功能。

import type { AxiosError, AxiosResponse, InternalAxiosRequestConfig } from 'axios'

export interface IOptions<
  T extends AxiosError = AxiosError,
  U extends AxiosResponse = AxiosResponse
> {
  maxRetryCount: number
  retryDelay: number
  request: (config: InternalAxiosRequestConfig) => Promise<AxiosResponse>
  isRetry: (error?: T, res?: U) => boolean
  beforeRetry?: (retryCount: number, error?: T, res?: U) => void
  failed?: (retryCount: number, error?: T, res?: U) => void
}
// 将最大重试次数与重试间隔参数改为可选参数
export type IConfig = Partial<IOptions> & Pick<IOptions, 'request'>

export class AxiosRetryPlugin {
  private options: IOptions
  private histories: Map<string, number>

  constructor(options: IConfig) {
    this.options = {
      maxRetryCount: options.maxRetryCount ?? 3,
      retryDelay: options.retryDelay ?? 1000,
      request: options.request,
      isRetry:
        options.isRetry ??
        ((err) => {
          if (err) {
            return true
          }
          return false
        })
    }

    this.histories = new Map()
  }
  
  static getDataType(obj: any) {
    let res = Object.prototype.toString.call(obj).split(' ')[1]
    res = res.substring(0, res.length - 1).toLowerCase()
    return res
  }

  static generateRequestKey(config: InternalAxiosRequestConfig): string {
    const { method, url, data, params } = config
    let key = `${method}-${url}`
    try {
      switch (AxiosRetryPlugin.getDataType(data)) {
        case 'object':
          key += `-${JSON.stringify(data)}`
          break
        case 'formdata':
          for (const [k, v] of data.entries()) {
            if (v instanceof Blob) {
              continue
            }
            key += `-${k}-${v}`
          }
          break
        default:
          break
      }

      if (AxiosRetryPlugin.getDataType(params) === 'object') {
        key += `-${JSON.stringify(params)}`
      }
    } catch (e) {/* empty */}
    return key
  }

  private fetch(config: InternalAxiosRequestConfig, beforeRetry?: () => void) {
    return new Promise<AxiosResponse>((resolve, reject) => {
      if (!this.options.retryDelay || this.options.retryDelay < 0) {
        // 新增的重试之前执行的钩子函数
        beforeRetry && beforeRetry()
        this.options.request(config).then(resolve).catch(reject)
        return
      }

      setTimeout(() => {
        // 新增的重试之前执行的钩子函数
        beforeRetry && beforeRetry()
        this.options.request(config).then(resolve).catch(reject)
      }, this.options.retryDelay)
    })
  }

  private retryRequest(error: AxiosError): Promise<AxiosResponse>
  private retryRequest(error: undefined, res: AxiosResponse): AxiosResponse
  private retryRequest(error?: AxiosError, res?: AxiosResponse) {
    // 判断是否重试
    if (!this.options.isRetry(error, res)) {
      if (error) {
        return Promise.reject(error)
      }
      return res!
    }

    let config: InternalAxiosRequestConfig
    if (error) {
      if (!error.config) {
        return Promise.reject(error)
      }
      config = error.config
    } else {
      config = res!.config
    }

    const key = AxiosRetryPlugin.generateRequestKey(config)
    const retryCount = this.histories.get(key) || 0
    
    if (retryCount >= this.options.maxRetryCount) {
      // 新增重试失败事件
      this.options.failed?.(retryCount, error, res)
      this.histories.delete(key)
      
      if (error) {
        return Promise.reject(error)
      }
      return res!
    }

    const newCount = retryCount + 1
    this.histories.set(key, newCount)
    
    return this.fetch(config, () => {
      this.options.beforeRetry?.(newCount, error, res)
    })
  }

  // 新增的响应成功处理方法
  responseInterceptorFulfilled(response: AxiosResponse) {
    return this.retryRequest(undefined, response)
  }

  responseInterceptorRejected(error: AxiosError) {
    return this.retryRequest(error)
  }
}

// 为什么用bind绑定this?因为注册axios拦截器时,axios会改变this指向
export default function createAxiosRetryPlugin(config: IConfig) {
  const instance = new AxiosRetryPlugin(config)

  return {
    responseInterceptorFulfilled: instance.responseInterceptorFulfilled.bind(instance),
    responseInterceptorRejected: instance.responseInterceptorRejected.bind(instance)
  }
}

使用示例:

import axios from 'axios'
import createAxiosRetryPlugin from '..'

const retryPlugin = createAxiosRetryPlugin({ 
  request: axios.request, 
  isRetry: (error?: AxiosError, res?: AxiosResponse) => {
    // 自定义何时重试
    return res?.data?.statusCode !== 200 || error?.response?.status === 500
  }
})
axios.interceptors.response.use(retryPlugin.responseInterceptorFulfilled, retryPlugin.responseInterceptorRejected)

至此,我们就完成了一个提供请求重试功能的axios插件开发了。

js版本

请求重试插件js版本代码,用法同上。

export class AxiosRetryPlugin {
  options = {}
  histories = new Map()

  constructor(options) {
    this.options = {
      request: options.request,
      maxRetryCount: options.maxRetryCount ?? 3,
      retryDelay: options.retryDelay ?? 1000,
      isRetry:
        options.isRetry ??
        ((err) => {
          if (err) {
            return true
          }

          return false
        })
    }

    this.histories = new Map()
  }

  static getDataType(obj) {
    let res = Object.prototype.toString.call(obj).split(' ')[1]
    res = res.substring(0, res.length - 1).toLowerCase()
    return res
  }

  static generateRequestKey(config) {
    const { method, url, data, params } = config
    let key = `${method}-${url}`

    try {
      switch (AxiosRetryPlugin.getDataType(data)) {
        case 'object':
          key += `-${JSON.stringify(data)}`
          break
        case 'formdata':
          for (const [k, v] of data.entries()) {
            if (v instanceof Blob) {
              continue
            }
            key += `-${k}-${v}`
          }
          break
        default:
          break
      }

      if (AxiosRetryPlugin.getDataType(params) === 'object') {
        key += `-${JSON.stringify(params)}`
      }
    } catch (e) {
      /* empty */
    }

    return key
  }

  fetch(config, beforeRetry) {
    return new Promise((resolve, reject) => {
      if (!this.options.retryDelay || this.options.retryDelay < 0) {
        beforeRetry && beforeRetry()
        this.options.request(config).then(resolve).catch(reject)
        return
      }

      setTimeout(() => {
        beforeRetry && beforeRetry()
        this.options.request(config).then(resolve).catch(reject)
      }, this.options.retryDelay)
    })
  }

  retryRequest(error, res) {
    if (!this.options.isRetry(error, res)) {
      if (error) {
        return Promise.reject(error)
      }

      return res
    }

    let config
    if (error) {
      if (!error.config) {
        return Promise.reject(error)
      }
      config = error.config
    } else {
      config = res.config
    }

    const key = AxiosRetryPlugin.generateRequestKey(config)
    const retryCount = this.histories.get(key) || 0

    if (retryCount >= this.options.maxRetryCount) {
      this.options.failed?.(retryCount, error, res)
      this.histories.delete(key)

      if (error) {
        return Promise.reject(error)
      }
      return res
    }

    const newCount = retryCount + 1
    this.histories.set(key, newCount)

    return this.fetch(config, () => {
      this.options.beforeRetry?.(newCount, error, res)
    })
  }

  responseInterceptorFulfilled(response) {
    return this.retryRequest(undefined, response)
  }

  responseInterceptorRejected(error) {
    return this.retryRequest(error)
  }
}

/**
 * 请求重试插件
 * @param {object} config 
 * @param {function} config.request 请求方法,返回Promise,接收axios的config参数
 * @param {number} [config.maxRetryCount] 最大重试次数,默认3
 * @param {number} [config.retryDelay] 重试间隔时间,默认1000ms
 * @param {function} [config.isRetry] 是否重试的判断函数,接收error和response,返回boolean
 * @param {function} [config.beforeRetry] 重试前的回调函数,接收重试次数、error和response
 * @param {function} [config.failed] 重试失败的回调函数,接收重试次数、error和response
 * @returns 
 */
export default function createAxiosRetryPlugin(config) {
  const instance = new AxiosRetryPlugin(config)

  return {
    responseInterceptorFulfilled: instance.responseInterceptorFulfilled.bind(instance),
    responseInterceptorRejected: instance.responseInterceptorRejected.bind(instance)
  }
}