diff --git a/__test__/AxiosRequestTemplate.retry.test.ts b/__test__/AxiosRequestTemplate.retry.test.ts deleted file mode 100644 index d637b92..0000000 --- a/__test__/AxiosRequestTemplate.retry.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import axios from 'axios'; -import { AxiosRequestTemplate, Cache } from '../src'; - -jest.mock('axios'); -const map = new Map(); -const timesMap = new Map(); -const mockCreate = () => { - return function (req) { - const { cancelToken, url, method, data, headers, params } = req; - const key = JSON.stringify({ url, method, params, data, headers }); - const times = timesMap.get(key) || 0; - return new Promise((res, rej) => { - timesMap.set(key, times + 1); - map.set(cancelToken, (msg?: string) => { - rej({ message: msg }); - }); - if (url === '/config') { - if (times === 3) { - setTimeout(() => res(req)); - return; - } - } - if (url === '3') { - if (times === 3) { - setTimeout(() => { - res({ code: 200, data: {}, msg: 'success' }); - }); - return; - } - } - setTimeout(() => { - if (times > 0) { - rej('times * ' + times); - return; - } - rej('404'); - }); - }); - }; -}; -(axios.CancelToken.source as any).mockImplementation(() => { - const token = Math.floor(Math.random() * 0xffffffffff).toString(16); - return { - token, - cancel(msg?: string) { - map.get(token)?.(msg); - }, - }; -}); - -(axios as any).create.mockImplementation(mockCreate); -(axios as any).isCancel = (value: any) => typeof value === 'object' && 'message' in value; - -describe('AxiosRequestTemplate retry', () => { - const get = new AxiosRequestTemplate().methodFactory('get'); - test('base', async () => { - // expect.assertions(4); - const list = [ - get<{ username: string; id: number }>('/user', { key: 1 }), - get<{ username: string; id: number }>('/user', { key: 2 }, { retry: 2 }), - get<{ username: string; id: number }>('/user', { key: 3 }, { retry: 10 }), - ]; - const res = await Promise.allSettled(list); - expect(res).toEqual([ - { - reason: '404', - status: 'rejected', - }, - { - reason: 'times * 2', - status: 'rejected', - }, - { - reason: 'times * 10', - status: 'rejected', - }, - ]); - - try { - await get<{ username: string; id: number }>('/user', { key: 4 }); - } catch (e) { - expect(e).toBe('404'); - } - - try { - await get<{ username: string; id: number }>('/user', { key: 5 }, { retry: 2 }); - } catch (e) { - expect(e).toBe('times * 2'); - } - try { - await get<{ username: string; id: number }>('/user', { key: 6 }, { retry: 10 }); - } catch (e) { - expect(e).toBe('times * 10'); - } - }); - test('第3次成功', async () => { - expect.assertions(2); - try { - await get<{ username: string; id: number }>( - '3', - { code: 200, data: {}, msg: 'success' }, - { tag: 'cancel', retry: 2 }, - ); - } catch (e) { - expect(e).toBe('times * 2'); - } - const res = await get<{ username: string; id: number }>('3', {}, { tag: 'cancel', retry: 3 }); - expect(res).toEqual({ code: 200, data: {}, msg: 'success' }); - }); - - test('cache&retry,有retry时不要用缓存', async () => { - const req = new AxiosRequestTemplate(); - const c = new Cache(); - const originSet = c.set; - const mockSet = jest.fn((...args: any) => originSet.apply(c, args)); - c.set = mockSet; - const originGet = c.get; - const mockGet = jest.fn((...args: any) => originGet.apply(c, args)); - c.get = mockGet; - (req as any).cache = c; - (req as any).afterRequest = () => void 0; - - const get = req.methodFactory('get'); - - const res = await get('/config', { test: 1 }, { retry: 10, cache: true }); - delete (res as any).cancelToken; - expect(res).toEqual({ - method: 'get', - url: '/config', - params: { test: 1 }, - }); - expect(mockGet.mock.calls.length).toBe(1); - expect(mockSet.mock.calls.length).toBe(4); - }); - - describe('cancel', () => { - const req = new AxiosRequestTemplate(); - const get = req.methodFactory('get'); - test('cancelAll', async () => { - expect.assertions(1); - try { - const p = get<{ username: string; id: number }>('/user', {}, { retry: 2 }); - req.cancelAll('cancel'); - await p; - } catch (e) { - expect(e).toEqual({ message: 'cancel' }); - } - }); - test('cancelWithTag', async () => { - expect.assertions(1); - try { - const p = get<{ username: string; id: number }>('/user', {}, { tag: 'cancel', retry: 2 }); - req.cancelWithTag('cancel', 'with tag'); - await p; - } catch (e) { - expect(e).toEqual({ message: 'with tag' }); - } - }); - test('cancelCurrentRequest', async () => { - expect.assertions(1); - try { - const p = get<{ username: string; id: number }>('/user', {}, { tag: 'cancel', retry: 2 }); - req.cancelCurrentRequest?.('cancel'); - await p; - } catch (e) { - expect(e).toEqual({ message: 'cancel' }); - } - }); - }); -}); diff --git a/__test__/AxiosRequestTemplate.test.ts b/__test__/AxiosRequestTemplate.test.ts index 117d0a1..bb74684 100644 --- a/__test__/AxiosRequestTemplate.test.ts +++ b/__test__/AxiosRequestTemplate.test.ts @@ -303,6 +303,7 @@ describe('AxiosRequestTemplate', () => { cache: { enable: true, }, + retry: {}, }, requestConfig: { method: 'get', @@ -313,6 +314,7 @@ describe('AxiosRequestTemplate', () => { { customConfig: { cache: {}, + retry: {}, }, requestConfig: { headers: { @@ -326,6 +328,7 @@ describe('AxiosRequestTemplate', () => { { customConfig: { cache: {}, + retry: {}, }, requestConfig: { data: {}, diff --git a/__test__/retry.test.ts b/__test__/retry.test.ts index 77344de..58e7548 100644 --- a/__test__/retry.test.ts +++ b/__test__/retry.test.ts @@ -1,26 +1,39 @@ import axios from 'axios'; -import { AxiosRequestTemplate, CustomConfig } from '../src'; +import { AxiosRequestTemplate, Cache } from '../src'; +import { mockAxiosResponse, sleep } from './utils'; jest.mock('axios'); const map = new Map(); -let times = 0; +const timesMap = new Map(); const mockCreate = () => { - return function ({ cancelToken, url }) { + return function (requestConfig) { + const { cancelToken, url, method, data, headers, params } = requestConfig; + const key = JSON.stringify({ url, method, params, data, headers }); + const times = timesMap.get(key) || 0; return new Promise((res, rej) => { + timesMap.set(key, times + 1); map.set(cancelToken, (msg?: string) => { rej({ message: msg }); }); + if (url === '/config') { + if (times === 3) { + setTimeout(() => res(mockAxiosResponse(requestConfig, requestConfig))); + return; + } + } if (url === '3') { if (times === 3) { setTimeout(() => { - res({ code: 200, data: {}, msg: 'success' }); + res(mockAxiosResponse(requestConfig, { code: 200, data: {}, msg: 'success' })); }); return; - } else { - times++; } } setTimeout(() => { + if (times > 0) { + rej('times * ' + times); + return; + } rej('404'); }); }); @@ -39,61 +52,14 @@ const mockCreate = () => { (axios as any).create.mockImplementation(mockCreate); (axios as any).isCancel = (value: any) => typeof value === 'object' && 'message' in value; -describe('retry', () => { - class RetryTemp extends AxiosRequestTemplate { - protected handleRetry(ctx) { - const { customConfig, clearSet } = ctx; - if (customConfig.retry === undefined || customConfig.retry < 1) return; - const maxTimex = customConfig.retry; - let status: 'running' | 'stop' = 'running'; - let times = 0; - const clear = () => { - status = 'stop'; - }; - if (customConfig.tag) { - this.tagCancelMap.get(customConfig.tag)?.push(clear); - } - this.cancelerSet.add(clear); - clearSet.add(clear); - ctx.retry = () => { - return new Promise((res, rej) => { - const handle = () => { - if (times >= maxTimex || status === 'stop') { - return rej('times * ' + times); - } - times++; - this.execRequest({ ...ctx }).then( - (data) => { - res(data); - }, - () => { - handle(); - }, - ); - }; - handle(); - }); - }; - } - - protected beforeRequest(ctx) { - super.beforeRequest(ctx); - this.handleRetry(ctx); - } - - protected handleError(ctx, e): any { - const { customConfig } = ctx; - if (customConfig.retry === undefined || axios.isCancel(e)) return super.handleError(ctx, e); - return ctx.retry(); - } - } - const get = new RetryTemp().methodFactory('get'); +describe('AxiosRequestTemplate retry', () => { + const get = new AxiosRequestTemplate().methodFactory('get'); test('base', async () => { - expect.assertions(4); + // expect.assertions(4); const list = [ - get<{ username: string; id: number }>('/user'), - get<{ username: string; id: number }>('/user', {}, { retry: 2 }), - get<{ username: string; id: number }>('/user', {}, { retry: 10 }), + get<{ username: string; id: number }>('/user', { key: 1 }), + get<{ username: string; id: number }>('/user', { key: 2 }, { retry: 2 }), + get<{ username: string; id: number }>('/user', { key: 3 }, { retry: { times: 10 } }), ]; const res = await Promise.allSettled(list); expect(res).toEqual([ @@ -112,18 +78,18 @@ describe('retry', () => { ]); try { - await get<{ username: string; id: number }>('/user'); + await get<{ username: string; id: number }>('/user', { key: 4 }); } catch (e) { expect(e).toBe('404'); } try { - await get<{ username: string; id: number }>('/user', {}, { retry: 2 }); + await get<{ username: string; id: number }>('/user', { key: 5 }, { retry: 2 }); } catch (e) { expect(e).toBe('times * 2'); } try { - await get<{ username: string; id: number }>('/user', {}, { retry: 10 }); + await get<{ username: string; id: number }>('/user', { key: 6 }, { retry: 10 }); } catch (e) { expect(e).toBe('times * 10'); } @@ -139,13 +105,61 @@ describe('retry', () => { } catch (e) { expect(e).toBe('times * 2'); } - times = 0; const res = await get<{ username: string; id: number }>('3', {}, { tag: 'cancel', retry: 3 }); expect(res).toEqual({ code: 200, data: {}, msg: 'success' }); }); + test('cache&retry,有retry时不要用缓存', async () => { + const req = new AxiosRequestTemplate(); + const c = new Cache(); + const originSet = c.set; + const mockSet = jest.fn((...args: any) => originSet.apply(c, args)); + c.set = mockSet; + const originGet = c.get; + const mockGet = jest.fn((...args: any) => originGet.apply(c, args)); + c.get = mockGet; + (req as any).cache = c; + (req as any).afterRequest = () => void 0; + + const get = req.methodFactory('get'); + + const res = await get('/config', { test: 1 }, { retry: 10, cache: true }); + delete (res as any).cancelToken; + expect(res).toEqual({ + method: 'get', + url: '/config', + params: { test: 1 }, + }); + expect(mockGet.mock.calls.length).toBe(1); + expect(mockSet.mock.calls.length).toBe(4); + }); + describe('immediate', () => { + test('use', async () => { + let res: any; + const p = get('/user', { use: 1 }, { retry: { times: 1, immediate: true, interval: 1000 } }); + p.catch((r) => (res = r)); + + await sleep(0); + expect(res).toBeUndefined(); + + await sleep(10); + expect(res).toBe('times * 1'); + }); + test('unused', async () => { + let res: any; + const p = get('/user', { use: 2 }, { retry: { times: 1, immediate: false, interval: 50 } }); + p.catch((r) => (res = r)); + + await sleep(20); + expect(res).toBeUndefined(); + + await sleep(50); + expect(res).toBe('times * 1'); + }); + }); + describe('cancel', () => { - const req = new RetryTemp(); + const req = new AxiosRequestTemplate(); const get = req.methodFactory('get'); test('cancelAll', async () => { expect.assertions(1); diff --git a/__test__/utils.ts b/__test__/utils.ts index c50c8b3..886ab58 100644 --- a/__test__/utils.ts +++ b/__test__/utils.ts @@ -1,5 +1,29 @@ +import { AxiosRequestConfig, AxiosResponse } from 'axios'; +import { ResType } from '../types'; + export async function sleep(delay: number) { return new Promise((res) => { setTimeout(() => res(), delay); }); } +export function mockAxiosResponse( + requestConfig: AxiosRequestConfig, + data: ResType, + status = 200, +): Partial { + return { + config: requestConfig, + data, + status, + }; +} +/*export function mockAxiosError( + requestConfig: AxiosRequestConfig, + data: any, +): { response: Partial } & Partial> { + return { + config: requestConfig, + response: mockAxiosResponse(requestConfig, data, 500), + isAxiosError: true, + }; +}*/ diff --git a/package.json b/package.json index 9814f40..41c2b20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "request-template", - "version": "0.0.8", + "version": "0.0.9", "description": "请求模板封装", "main": "dist/index.js", "typings": "./types/", diff --git a/src/AxiosRequestTemplate.ts b/src/AxiosRequestTemplate.ts index 1869c5a..cd13c84 100644 --- a/src/AxiosRequestTemplate.ts +++ b/src/AxiosRequestTemplate.ts @@ -1,4 +1,4 @@ -import type { ResType, CustomConfig, DynamicCustomConfig } from './types'; +import type { ResType, CustomConfig, DynamicCustomConfig, RetryConfig } from './types'; import axios, { AxiosInstance, AxiosPromise, @@ -9,7 +9,7 @@ import axios, { AxiosError, } from 'axios'; import { Cache } from './Cache'; -import { Context, CustomCacheConfig } from './types'; +import { Context, CustomCacheConfig, RetryContext } from './types'; // 使用模板方法模式处理axios请求, 具体类可实现protected方法替换掉原有方法 export class AxiosRequestTemplate { @@ -51,10 +51,12 @@ export class AxiosRequestTemplate { protected handleResponse(ctx: Context, response: AxiosResponse): ResType { return response?.data as ResType; } + // 获取拦截器 protected get interceptors() { return this.axiosIns.interceptors; } + protected setInterceptors() { // 重写此函数会在Request中调用 // example @@ -92,11 +94,13 @@ export class AxiosRequestTemplate { }; clearSet.add(clearCanceler); // 取消 + // 注意:对于retry的无效,无法判断时机 this.cancelCurrentRequest = (msg) => { cancel(msg); clearCanceler(); }; } + // 处理requestConfig protected handleRequestConfig( url: string, @@ -107,6 +111,7 @@ export class AxiosRequestTemplate { finalConfig.method = finalConfig.method || 'get'; return finalConfig; } + // 合并缓存配置 protected mergeCacheConfig(cacheConfig: CustomConfig['cache']): CustomCacheConfig { function merge(cache: CustomConfig['cache'], base: CustomCacheConfig = {}) { @@ -125,12 +130,30 @@ export class AxiosRequestTemplate { } return merge(cacheConfig, merge(this.globalCustomConfig.cache)); } + + protected mergeRetryConfig(retryConfig: CustomConfig['retry']): RetryConfig { + function merge(retry: CustomConfig['retry'], base: RetryConfig = {}) { + switch (typeof retry) { + case 'number': + base.times = retry; + break; + case 'object': + base = { ...base, ...retry }; + break; + } + return base; + } + return merge(retryConfig, merge(this.globalCustomConfig.retry)); + } + // 处理CustomConfig protected handleCustomConfig(customConfig: CC) { const config = { ...this.globalCustomConfig, ...customConfig }; config.cache = this.mergeCacheConfig(customConfig.cache); + config.retry = this.mergeRetryConfig(customConfig.retry); return config; } + // 处理请求用的数据 protected handleRequestData(ctx: Context, data: {}) { const { requestConfig } = ctx; @@ -142,6 +165,7 @@ export class AxiosRequestTemplate { } requestConfig.data = data; } + // 处理响应结果 protected handleStatus( ctx: Context, @@ -161,7 +185,7 @@ export class AxiosRequestTemplate { } // 请求 - protected execRequest(ctx: Context & { isRetry?: boolean }) { + protected fetch(ctx: RetryContext) { const { requestConfig, customConfig, requestKey } = ctx; // 使用缓存 const cacheConfig = customConfig.cache as CustomCacheConfig; @@ -183,50 +207,113 @@ export class AxiosRequestTemplate { } protected handleRetry(ctx: Context) { + // 太长了 以后可优化 const { customConfig, clearSet } = ctx; - if (customConfig.retry === undefined || customConfig.retry < 1) return; - const maxTimex = customConfig.retry; - let status: 'running' | 'stop' = 'running'; + const retryConfig = customConfig.retry as RetryConfig; + + if (retryConfig.times === undefined || retryConfig.times < 1) return; + + const maxTimex = retryConfig.times; let times = 0; - const clear = () => { - status = 'stop'; + let timer: NodeJS.Timer; + let reject = (): any => undefined; + const stop = () => { + clearTimeout(timer); + reject(); }; + if (customConfig.tag) { - this.tagCancelMap.get(customConfig.tag)?.push(clear); + this.tagCancelMap.get(customConfig.tag)?.push(stop); } - this.cancelerSet.add(clear); - clearSet.add(clear); + this.cancelerSet.add(stop); + clearSet.add(stop); + ctx.retry = (e: AxiosError>) => { return new Promise((res, rej) => { - const handle = (e) => { - if (times >= maxTimex || status === 'stop') { - return rej(e); + // retry期间取消,则返回上一次的结果 + reject = () => rej(e); + const startRetry = () => { + if (times >= maxTimex) { + reject(); + return; } + // 立即执行时,间隔为undefined;否则为interval + const timeout = + times === 0 + ? retryConfig.immediate + ? void 0 + : retryConfig.interval + : retryConfig.interval; + + timer = setTimeout(retry, timeout); + }; + const retry = () => { times++; this.execRequest({ ...ctx, isRetry: true }).then( - (data) => { - res(data); - }, - (e) => { - handle(e); + (data) => res(data as AxiosResponse), + (error) => { + e = error; + startRetry(); }, ); }; - handle(e); + startRetry(); }); }; } - protected beforeRequest(ctx: Context) { + protected beforeExecRequest(ctx: Context) { this.handleCanceler(ctx); + } + + protected beforeRequest(ctx: Context) { this.handleRetry(ctx); } protected afterRequest(ctx: Context) { + // 处理清理canceler等操作 ctx.clearSet.forEach((clear) => clear()); ctx.clearSet.clear(); } + protected generateContext( + url: string, + data: {}, + customConfig: CC, + requestConfig: AxiosRequestConfig, + ) { + // 处理配置 + requestConfig = this.handleRequestConfig(url, requestConfig); + customConfig = this.handleCustomConfig(customConfig); + const ctx: Context = { + requestConfig, + customConfig, + requestKey: '', + clearSet: new Set(), + }; + ctx.requestKey = this.generateRequestKey(ctx); + this.handleRequestData(ctx, data); + return ctx; + } + + protected async execRequest(ctx: RetryContext) { + try { + this.beforeExecRequest(ctx); + // 请求 + const response: AxiosResponse = await this.fetch(ctx); + // 请求结果数据结构处理 + const data = this.handleResponse(ctx, response); + // 状态码处理,并返回结果 + return this.handleStatus(ctx, response, data); + } catch (e: any) { + // 重试 + if (!ctx.isRetry && ctx.retry && !axios.isCancel(e)) { + return ctx.retry(e); + } + return Promise.reject(e); + } + } + // 模板方法,最终请求所使用的方法。 // 可子类覆盖,如非必要不建议子类覆盖 request( @@ -235,54 +322,33 @@ export class AxiosRequestTemplate { customConfig?: DynamicCustomConfig, requestConfig?: Omit, ): Promise> : ResType>; - async request( + async request( url: string, data: {} = {}, customConfig = {} as CC, requestConfig: AxiosRequestConfig = {}, ): Promise { - // 1、处理配置 - requestConfig = this.handleRequestConfig(url, requestConfig); - customConfig = this.handleCustomConfig(customConfig); - const ctx: Context = { - requestConfig, - customConfig, - requestKey: '', - clearSet: new Set(), - }; - ctx.requestKey = this.generateRequestKey(ctx); - this.handleRequestData(ctx, data); - // 2、处理cancel handler等等 + const ctx = this.generateContext(url, data, customConfig, requestConfig); this.beforeRequest(ctx); try { - // 3、请求 - const response: AxiosResponse = await this.execRequest(ctx); - // 4、请求结果数据结构处理 - const data = this.handleResponse(ctx, response); - // 5、状态码处理,并返回结果 - return this.handleStatus(ctx, response, data); - } catch (error: any) { - const e = error as AxiosError>; - // 错误处理 - const response = e.response as AxiosResponse>; - // 4、请求结果数据结构处理 - const data = this.handleResponse(ctx, response); - if (data && data.code !== undefined) { - // 5、状态码处理,并返回结果 - return this.handleStatus(ctx, response, data); - } - // 如未命中error处理 则再次抛出error + return await this.execRequest(ctx); + } catch (e: any) { return await this.handleError(ctx, e); } finally { - // 6、处理清理canceler等操作 this.afterRequest(ctx); } } - protected handleError(ctx: Context, e: AxiosError>): any { - const { customConfig } = ctx; - if (customConfig.retry === undefined || axios.isCancel(e)) throw e; - return ctx.retry?.(e); + protected handleError(ctx: Context, e: AxiosError>) { + // 错误处理 + const response = e.response as AxiosResponse>; + // 4、请求结果数据结构处理 + const data = this.handleResponse(ctx, response); + if (data && data.code !== undefined) { + // 5、状态码处理,并返回结果 + return this.handleStatus(ctx, response, data); + } + return Promise.reject(e); } // 取消所有请求 diff --git a/src/types.ts b/src/types.ts index 1900572..a4773bd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,4 @@ -import type { AxiosRequestConfig, AxiosResponse } from 'axios'; +import type { AxiosPromise, AxiosRequestConfig, AxiosResponse } from 'axios'; import { AxiosError } from 'axios'; export type StatusHandler = ( @@ -17,15 +17,26 @@ export interface CustomCacheConfig { enable?: boolean; timeout?: number; } +export interface RetryConfig { + times?: number; + interval?: number; + immediate?: boolean; +} -// CustomConfig +// 自定义配置 export interface CustomConfig { - returnRes?: boolean; // 返回res - silent?: boolean; // 报错不弹窗 + // 是否返回axios的response + returnRes?: boolean; + // 报错不弹窗,需要自己实现 + silent?: boolean; + // 状态处理 statusHandlers?: StatusHandlers; + // 缓存配置 cache?: boolean | CustomCacheConfig; + // 标签,用于取消请求 tag?: string; - retry?: number; + // 失败重试次数 + retry?: number | RetryConfig; } export interface ResType { @@ -45,5 +56,9 @@ export interface Context { requestConfig: AxiosRequestConfig; clearSet: Set; requestKey: string; - retry?: (e: AxiosError>) => Promise; + retry?: (e: AxiosError>) => AxiosPromise; +} + +export interface RetryContext extends Context { + isRetry?: boolean; }