From 2982ed5962de00b1821305813f6242256e15413c Mon Sep 17 00:00:00 2001 From: Philippe Clesca Date: Wed, 25 Oct 2023 16:37:18 -0400 Subject: [PATCH 1/7] Api request class for better request management --- packages/plexus-api/src/request.ts | 163 +++++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 packages/plexus-api/src/request.ts diff --git a/packages/plexus-api/src/request.ts b/packages/plexus-api/src/request.ts new file mode 100644 index 0000000..f7084ca --- /dev/null +++ b/packages/plexus-api/src/request.ts @@ -0,0 +1,163 @@ +import { PlexusError } from '@plexusjs/utils' +import { ApiInstance } from './api' +import { PlexusApiRes, PlexusApiSendOptions } from './types' + +export class ApiRequest { + constructor( + public api: ApiInstance, + public path: string, + public options: Partial<{ requestOptions: PlexusApiSendOptions }> + ) {} + /** + * Send a request to the server + * @param path + * @param options + */ + private async send( + path: string, + options: PlexusApiSendOptions + ): Promise> { + // if we don't have fetch, return a blank response object + if (this.api.config.noFetch) + return ApiRequest.createEmptyRes() + + const pureHeaders = await this.api.headerGetter() + + const headers = { + ...pureHeaders, + ...(options.headers ?? {}), + } + + if (!headers['Content-Type']) { + if (options.body !== undefined) { + headers['Content-Type'] = 'application/json' + } else { + headers['Content-Type'] = 'text/html' + } + } + // init values used later + let timedOut = false + let res: Response | undefined + try { + // build out the URI + const matches = path.match(/^http(s)?/g) + const uri = + matches && matches?.length > 0 + ? path + : `${this.api.config.baseURL}${ + path.startsWith('/') || path?.length === 0 ? path : `/${path}` + }` + + const controller = new AbortController() + const requestObject = { + ...this.api.config.options, + ...options, + headers, + signal: controller.signal, + } + // if we have a timeout set, call fetch and set a timeout. If the fetch takes longer than the timeout length, kill thee request and return a blank response + if (this.api.config.timeout) { + let to: any + const timeout = new Promise((resolve, reject) => { + to = setTimeout(() => { + timedOut = true + resolve() + }, this.api.config.timeout) + }) + const request = new Promise((resolve, reject) => { + fetch(uri, requestObject) + .then((response) => { + clearTimeout(to) + resolve(response) + }) + .catch(reject) + }) + + // race the timeout and the request + const raceResult = await Promise.race([timeout, request]) + + if (raceResult) { + res = raceResult + } else { + if (this.api.config.abortOnTimeout) controller.abort() + + // if we're throwing, throw an error + if (this.api.config.throws) + throw new PlexusError('Request timed out', { type: 'api' }) + // a 504 response status means the programmatic timeout was surpassed + return ApiRequest.createEmptyRes( + timedOut ? 504 : res?.status ?? 513 + ) + } + } + // if we don't have a timeout set, just try to fetch + else { + res = await fetch(uri, requestObject) + } + } catch (e) { + // if silentFail is enabled, don't throw the error; Otherwise, throw an error + if (!this.api.config.silentFail) { + throw e + } + } + let data: ResponseDataType + let rawData: string + let blob: Blob + // we never got a response + if (res === undefined) { + return ApiRequest.createEmptyRes(500) + } + + const hasCookie = (cName: string): boolean => { + return res?.headers?.get('set-cookie')?.includes(cName) ?? false + } + const ok = res.status > 199 && res.status < 300 + + // if we got a response, parse it and return it + if (res.status >= 200 && res.status < 600) { + const text = await res.text() + let parsed: ResponseDataType = undefined as any + try { + parsed = JSON.parse(text || '{}') as ResponseDataType + } catch (e) {} + data = parsed ?? ({} as ResponseDataType) + rawData = text + blob = new Blob([text], { type: 'text/plain' }) + + const pResponse = { + status: res.status, + response: res, + rawData, + blob, + ok, + data, + hasCookie, + } + // if(this._internalStore.onResponse) this._internalStore.onResponse(req, pResponse) + if (this.api.config.throws && !ok) { + throw pResponse + } + return pResponse + } + // if we got a response, but it's not in the 200~600 range, return it + return { + status: res.status, + response: res, + rawData: '', + ok, + data: {} as ResponseDataType, + hasCookie, + } + } + + static createEmptyRes(status: number = 408) { + return { + status, + response: {} as Response, + rawData: '', + data: {} as ResponseDataType, + ok: status > 199 && status < 300, + hasCookie: (name: string) => false, + } + } +} From 89c4ae8b510c1f3a8afb3a9e6bc767503f1b367d Mon Sep 17 00:00:00 2001 From: Philippe Clesca Date: Wed, 25 Oct 2023 16:37:32 -0400 Subject: [PATCH 2/7] api now uses api request classes --- packages/plexus-api/src/api.ts | 185 +++++---------------------------- 1 file changed, 25 insertions(+), 160 deletions(-) diff --git a/packages/plexus-api/src/api.ts b/packages/plexus-api/src/api.ts index 522c39d..a371324 100644 --- a/packages/plexus-api/src/api.ts +++ b/packages/plexus-api/src/api.ts @@ -9,6 +9,7 @@ import { PlexusApiFetchOptions, } from './types' import { PlexusError } from '@plexusjs/utils' +import { ApiRequest } from './request' // let's get Blob from Node.js or browser let Blob if (typeof window === 'undefined') { @@ -43,6 +44,8 @@ export class ApiInstance { | Record | Promise> = () => ({}) + private requestMap: Map = new Map() + private disabled = false // ":": [() => void, ...] private waitingQueues: Map Promise)[]> = new Map() @@ -55,6 +58,7 @@ export class ApiInstance { optionsInit: { ...config.defaultOptions }, timeout: config.timeout || undefined, abortOnTimeout: config.abortOnTimeout ?? true, + retry: config.retry || undefined, baseURL: baseURL.endsWith('/') && baseURL.length > 1 ? baseURL.substring(0, baseURL.length - 1) @@ -76,147 +80,6 @@ export class ApiInstance { } config.headers && this.setHeaders(config.headers) } - /** - * Send a request to the server - * @param path - * @param options - */ - private async makeRequest( - path: string, - options: PlexusApiSendOptions - ): Promise> { - // if we don't have fetch, return a blank response object - if (this._internalStore.noFetch) - return ApiInstance.createEmptyRes() - - const pureHeaders = await this.headerGetter() - - const headers = { - ...pureHeaders, - ...(options.headers ?? {}), - } - - if (!headers['Content-Type']) { - if (options.body !== undefined) { - headers['Content-Type'] = 'application/json' - } else { - headers['Content-Type'] = 'text/html' - } - } - // init values used later - let timedOut = false - let res: Response | undefined - try { - // build out the URI - const matches = path.match(/^http(s)?/g) - const uri = - matches && matches?.length > 0 - ? path - : `${this._internalStore.baseURL}${ - path.startsWith('/') || path?.length === 0 ? path : `/${path}` - }` - - const controller = new AbortController() - const requestObject = { - ...this._internalStore.options, - ...options, - headers, - signal: controller.signal, - } - // if we have a timeout set, call fetch and set a timeout. If the fetch takes longer than the timeout length, kill thee request and return a blank response - if (this._internalStore.timeout) { - let to: any - const timeout = new Promise((resolve, reject) => { - to = setTimeout(() => { - timedOut = true - resolve() - }, this._internalStore.timeout) - }) - const request = new Promise((resolve, reject) => { - fetch(uri, requestObject) - .then((response) => { - clearTimeout(to) - resolve(response) - }) - .catch(reject) - }) - - // race the timeout and the request - const raceResult = await Promise.race([timeout, request]) - - if (raceResult) { - res = raceResult - } else { - if (this._internalStore.abortOnTimeout) controller.abort() - - // if we're throwing, throw an error - if (this._internalStore.throws) - throw new PlexusError('Request timed out', { type: 'api' }) - // a 504 response status means the programmatic timeout was surpassed - return ApiInstance.createEmptyRes( - timedOut ? 504 : res?.status ?? 513 - ) - } - } - // if we don't have a timeout set, just try to fetch - else { - res = await fetch(uri, requestObject) - } - } catch (e) { - // if silentFail is enabled, don't throw the error; Otherwise, throw an error - if (!this._internalStore.silentFail) { - throw e - } - } - let data: ResponseDataType - let rawData: string - let blob: Blob - // we never got a response - if (res === undefined) { - return ApiInstance.createEmptyRes(500) - } - - const hasCookie = (cName: string): boolean => { - return res?.headers?.get('set-cookie')?.includes(cName) ?? false - } - const ok = res.status > 199 && res.status < 300 - - // if we got a response, parse it and return it - if (res.status >= 200 && res.status < 600) { - const text = await res.text() - let parsed: ResponseDataType = undefined as any - try { - parsed = JSON.parse(text || '{}') as ResponseDataType - } catch (e) {} - data = parsed ?? ({} as ResponseDataType) - rawData = text - blob = new Blob([text], { type: 'text/plain' }) - - const pResponse = { - status: res.status, - response: res, - rawData, - blob, - ok, - data, - hasCookie, - } - // if(this._internalStore.onResponse) this._internalStore.onResponse(req, pResponse) - if (this._internalStore.throws && !ok) { - throw pResponse - } - return pResponse - } - // if we got a response, but it's not in the 200~600 range, return it - return { - status: res.status, - response: res, - rawData: '', - ok, - data: {} as ResponseDataType, - hasCookie, - } - } /** * Do some pre-send stuff @@ -227,9 +90,17 @@ export class ApiInstance { path: string, options: PlexusApiSendOptions ) { - if (this.disabled) return ApiInstance.createEmptyRes(0) + if (this.disabled) return ApiRequest.createEmptyRes(0) // this.addToQueue(`${this.genKey('GET', path)}`, () => {}) - const res = await this.makeRequest(path, options) + let request: ApiRequest + if (!this.requestMap.has(path)) { + request = new ApiRequest(this, path, { requestOptions: options }) + this.requestMap.set(path, request) + } else { + request = this.requestMap.get(path) as ApiRequest + } + + const res = await request.send(path, options) const headers = await this.headerGetter() this._internalStore.onResponse?.( { @@ -314,7 +185,7 @@ export class ApiInstance { */ async post< ResponseType = any, - BodyType extends Record | string = {}, + BodyType extends Record | string = {} >( path: string, body: BodyType = {} as BodyType, @@ -467,7 +338,7 @@ export class ApiInstance { setHeaders< HeaderFunction extends () => | Record - | Promise>, + | Promise> >(inputFnOrObj: HeaderFunction | Record) { // if (!_headers) _internalStore._options.headers = {} if (this._internalStore.noFetch) return this @@ -541,26 +412,20 @@ export class ApiInstance { get config() { return Object.freeze( deepClone({ - ...this._internalStore.options, - headers: ApiInstance.parseHeaders(this._headers), + ...this._internalStore, + + options: { + ...this._internalStore.options, + headers: ApiInstance.parseHeaders(this._headers), + } as { + headers: Record + } & RequestInit, }) - ) as { - headers: Record - } & RequestInit + ) } enabled(status: boolean = true) { this.disabled = !status } - private static createEmptyRes(status: number = 408) { - return { - status, - response: {} as Response, - rawData: '', - data: {} as ResponseDataType, - ok: status > 199 && status < 300, - hasCookie: (name: string) => false, - } - } } export function api( From 6fecb0fdf5f2400ccf2a64de475b80aec122b509 Mon Sep 17 00:00:00 2001 From: Philippe Clesca Date: Wed, 25 Oct 2023 16:38:47 -0400 Subject: [PATCH 3/7] cleanup --- packages/plexus-api/src/request.ts | 4 ++-- packages/plexus-api/src/types.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/plexus-api/src/request.ts b/packages/plexus-api/src/request.ts index f7084ca..447fbf6 100644 --- a/packages/plexus-api/src/request.ts +++ b/packages/plexus-api/src/request.ts @@ -21,10 +21,10 @@ export class ApiRequest { if (this.api.config.noFetch) return ApiRequest.createEmptyRes() - const pureHeaders = await this.api.headerGetter() + const instanceHeaders = await this.api.headers const headers = { - ...pureHeaders, + ...instanceHeaders, ...(options.headers ?? {}), } diff --git a/packages/plexus-api/src/types.ts b/packages/plexus-api/src/types.ts index 973533b..ff4ace4 100644 --- a/packages/plexus-api/src/types.ts +++ b/packages/plexus-api/src/types.ts @@ -10,6 +10,7 @@ export interface PlexusApiRes { export interface PlexusApiConfig { defaultOptions?: PlexusApiOptions timeout?: number + retry?: number abortOnTimeout?: boolean // Deprecated silentFail?: boolean @@ -55,6 +56,7 @@ export interface ApiStore { options: PlexusApiOptions optionsInit: PlexusApiOptions timeout: number | undefined + retry: number | undefined abortOnTimeout: boolean baseURL: string noFetch: boolean From e6e04929527a972292c1867197fc9ff62af5d2c3 Mon Sep 17 00:00:00 2001 From: Philippe Clesca Date: Wed, 25 Oct 2023 17:08:22 -0400 Subject: [PATCH 4/7] =?UTF-8?q?retry=20should=20work=20=F0=9F=A4=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/plexus-api/src/api.ts | 11 +++++++---- packages/plexus-api/src/request.ts | 22 +++++++++++++++++----- packages/plexus-api/src/types.ts | 12 ++++++++++++ tests/edgecases.test.tsx | 2 +- 4 files changed, 37 insertions(+), 10 deletions(-) diff --git a/packages/plexus-api/src/api.ts b/packages/plexus-api/src/api.ts index a371324..55aed38 100644 --- a/packages/plexus-api/src/api.ts +++ b/packages/plexus-api/src/api.ts @@ -7,6 +7,7 @@ import { PlexusApiRes, PlexusApiSendOptions, PlexusApiFetchOptions, + PlexusApiInstanceConfig, } from './types' import { PlexusError } from '@plexusjs/utils' import { ApiRequest } from './request' @@ -51,7 +52,7 @@ export class ApiInstance { private waitingQueues: Map Promise)[]> = new Map() constructor( baseURL: string = '', - config: PlexusApiConfig = { defaultOptions: {} } + config: PlexusApiInstanceConfig = { defaultOptions: {} } ) { this._internalStore = { options: config.defaultOptions ?? {}, @@ -69,6 +70,8 @@ export class ApiInstance { silentFail: config.silentFail ?? false, onResponse: config.onResponse, } + + // if we don't have fetch, set noFetch to true try { fetch } catch (e) { @@ -82,7 +85,7 @@ export class ApiInstance { } /** - * Do some pre-send stuff + * Send a request to the api instance * @param path * @param options */ @@ -185,7 +188,7 @@ export class ApiInstance { */ async post< ResponseType = any, - BodyType extends Record | string = {} + BodyType extends Record | string = {}, >( path: string, body: BodyType = {} as BodyType, @@ -338,7 +341,7 @@ export class ApiInstance { setHeaders< HeaderFunction extends () => | Record - | Promise> + | Promise>, >(inputFnOrObj: HeaderFunction | Record) { // if (!_headers) _internalStore._options.headers = {} if (this._internalStore.noFetch) return this diff --git a/packages/plexus-api/src/request.ts b/packages/plexus-api/src/request.ts index 447fbf6..a3f866d 100644 --- a/packages/plexus-api/src/request.ts +++ b/packages/plexus-api/src/request.ts @@ -1,19 +1,30 @@ import { PlexusError } from '@plexusjs/utils' import { ApiInstance } from './api' -import { PlexusApiRes, PlexusApiSendOptions } from './types' +import { PlexusApiConfig, PlexusApiRes, PlexusApiSendOptions } from './types' export class ApiRequest { + private attempts = 0 constructor( public api: ApiInstance, public path: string, - public options: Partial<{ requestOptions: PlexusApiSendOptions }> + public config: Partial<{ requestOptions: PlexusApiSendOptions }> & + PlexusApiConfig ) {} + + private retry(path: string, options: PlexusApiSendOptions) { + if (this.config.retry) { + if (this.attempts < this.config.retry) { + this.attempts++ + return this.send(path, options) + } + } + } /** * Send a request to the server * @param path * @param options */ - private async send( + async send( path: string, options: PlexusApiSendOptions ): Promise> { @@ -21,7 +32,7 @@ export class ApiRequest { if (this.api.config.noFetch) return ApiRequest.createEmptyRes() - const instanceHeaders = await this.api.headers + const instanceHeaders = this.api.headers const headers = { ...instanceHeaders, @@ -95,8 +106,9 @@ export class ApiRequest { res = await fetch(uri, requestObject) } } catch (e) { + this.retry(path, options) // if silentFail is enabled, don't throw the error; Otherwise, throw an error - if (!this.api.config.silentFail) { + if (!this.config.throws) { throw e } } diff --git a/packages/plexus-api/src/types.ts b/packages/plexus-api/src/types.ts index ff4ace4..019f16d 100644 --- a/packages/plexus-api/src/types.ts +++ b/packages/plexus-api/src/types.ts @@ -8,6 +8,18 @@ export interface PlexusApiRes { hasCookie: (cookieName: string) => boolean } export interface PlexusApiConfig { + defaultOptions?: PlexusApiOptions + timeout?: number + retry?: number + abortOnTimeout?: boolean + throws?: boolean + onResponse?: (req: PlexusApiReq, res: PlexusApiRes) => void + headers?: + | Record + | (() => Record) + | (() => Promise>) +} +export interface PlexusApiInstanceConfig { defaultOptions?: PlexusApiOptions timeout?: number retry?: number diff --git a/tests/edgecases.test.tsx b/tests/edgecases.test.tsx index d15108a..3f2ec3f 100644 --- a/tests/edgecases.test.tsx +++ b/tests/edgecases.test.tsx @@ -89,7 +89,7 @@ describe('Collection Relations', () => { ) test('Batching race condition with selectors', () => { - batch(() => { }) + batch(() => {}) }) }) From f833469f7358985dd06b4626f79ce0d0759cc1a5 Mon Sep 17 00:00:00 2001 From: Philippe Clesca Date: Wed, 25 Oct 2023 17:19:39 -0400 Subject: [PATCH 5/7] localized request configs --- packages/plexus-api/src/api.ts | 11 ++++-- packages/plexus-api/src/request.ts | 14 +++---- packages/plexus-api/src/types.ts | 13 +------ tests/api.test.ts | 61 +++++++++++++++++++++++------- 4 files changed, 61 insertions(+), 38 deletions(-) diff --git a/packages/plexus-api/src/api.ts b/packages/plexus-api/src/api.ts index 55aed38..f9613ef 100644 --- a/packages/plexus-api/src/api.ts +++ b/packages/plexus-api/src/api.ts @@ -103,7 +103,12 @@ export class ApiInstance { request = this.requestMap.get(path) as ApiRequest } - const res = await request.send(path, options) + // if we don't have fetch, return a blank response object + + const res = this.config.noFetch + ? ApiRequest.createEmptyRes() + : await request.send(path, options) + const headers = await this.headerGetter() this._internalStore.onResponse?.( { @@ -188,7 +193,7 @@ export class ApiInstance { */ async post< ResponseType = any, - BodyType extends Record | string = {}, + BodyType extends Record | string = {} >( path: string, body: BodyType = {} as BodyType, @@ -341,7 +346,7 @@ export class ApiInstance { setHeaders< HeaderFunction extends () => | Record - | Promise>, + | Promise> >(inputFnOrObj: HeaderFunction | Record) { // if (!_headers) _internalStore._options.headers = {} if (this._internalStore.noFetch) return this diff --git a/packages/plexus-api/src/request.ts b/packages/plexus-api/src/request.ts index a3f866d..53ede7e 100644 --- a/packages/plexus-api/src/request.ts +++ b/packages/plexus-api/src/request.ts @@ -28,10 +28,6 @@ export class ApiRequest { path: string, options: PlexusApiSendOptions ): Promise> { - // if we don't have fetch, return a blank response object - if (this.api.config.noFetch) - return ApiRequest.createEmptyRes() - const instanceHeaders = this.api.headers const headers = { @@ -67,13 +63,13 @@ export class ApiRequest { signal: controller.signal, } // if we have a timeout set, call fetch and set a timeout. If the fetch takes longer than the timeout length, kill thee request and return a blank response - if (this.api.config.timeout) { + if (this.config.timeout) { let to: any const timeout = new Promise((resolve, reject) => { to = setTimeout(() => { timedOut = true resolve() - }, this.api.config.timeout) + }, this.config.timeout) }) const request = new Promise((resolve, reject) => { fetch(uri, requestObject) @@ -90,10 +86,10 @@ export class ApiRequest { if (raceResult) { res = raceResult } else { - if (this.api.config.abortOnTimeout) controller.abort() + if (this.config.abortOnTimeout) controller.abort() // if we're throwing, throw an error - if (this.api.config.throws) + if (this.config.throws) throw new PlexusError('Request timed out', { type: 'api' }) // a 504 response status means the programmatic timeout was surpassed return ApiRequest.createEmptyRes( @@ -146,7 +142,7 @@ export class ApiRequest { hasCookie, } // if(this._internalStore.onResponse) this._internalStore.onResponse(req, pResponse) - if (this.api.config.throws && !ok) { + if (this.config.throws && !ok) { throw pResponse } return pResponse diff --git a/packages/plexus-api/src/types.ts b/packages/plexus-api/src/types.ts index 019f16d..8eca242 100644 --- a/packages/plexus-api/src/types.ts +++ b/packages/plexus-api/src/types.ts @@ -19,20 +19,9 @@ export interface PlexusApiConfig { | (() => Record) | (() => Promise>) } -export interface PlexusApiInstanceConfig { - defaultOptions?: PlexusApiOptions - timeout?: number - retry?: number - abortOnTimeout?: boolean +export interface PlexusApiInstanceConfig extends PlexusApiConfig { // Deprecated silentFail?: boolean - - throws?: boolean - onResponse?: (req: PlexusApiReq, res: PlexusApiRes) => void - headers?: - | Record - | (() => Record) - | (() => Promise>) } export interface PlexusApiReq { path: string diff --git a/tests/api.test.ts b/tests/api.test.ts index 33c6ab0..9d1fdfd 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -22,8 +22,8 @@ describe('Testing Api Function', () => { console.log(myApi.config) // console.log(myApi.config) expect(myApi.config).toBeDefined() - expect(myApi.config.headers).toBeDefined() - expect(myApi.config.headers['custom']).toBe('header') + expect(myApi.config.options.headers).toBeDefined() + expect(myApi.config.options.headers['custom']).toBe('header') const res = await myApi.get('https://google.com') expect(res?.status).toBeGreaterThan(0) @@ -43,8 +43,8 @@ describe('Testing Api Function', () => { }) // console.log(myApi.config) expect(apiUsingOnResponse.config).toBeDefined() - expect(apiUsingOnResponse.config.headers).toBeDefined() - expect(apiUsingOnResponse.config.headers['custom']).toBe('header') + expect(apiUsingOnResponse.headers).toBeDefined() + expect(apiUsingOnResponse.headers['custom']).toBe('header') const res = await apiUsingOnResponse.get('https://google.com') expect(res?.status).toBeGreaterThan(0) @@ -60,10 +60,6 @@ describe('Testing Api Function', () => { custom: 'header', }, }) - // console.log(myApi.config) - expect(apiUsingOnResponse.config).toBeDefined() - expect(apiUsingOnResponse.config.headers).toBeDefined() - expect(apiUsingOnResponse.config.headers['custom']).toBe('header') await expect( apiUsingOnResponse.post('https://google.com/this/url/doesnt/exist') @@ -81,10 +77,6 @@ describe('Testing Api Function', () => { custom: 'header', }, }) - // console.log(myApi.config) - expect(apiUsingOnResponse.config).toBeDefined() - expect(apiUsingOnResponse.config.headers).toBeDefined() - expect(apiUsingOnResponse.config.headers['custom']).toBe('header') await expect( apiUsingOnResponse.post('https://google.com/this/url/doesnt/exist') @@ -101,6 +93,47 @@ describe('Testing Api Function', () => { let errorOccurred = false + try { + await apiUsingOnResponse.post('http://httpstat.us/526?sleep=2800') + } catch (error) { + console.log(error) + errorOccurred = true + } + expect(errorOccurred).toBe(true) + + // Wait for the sleep duration of the request endpoint + await new Promise((resolve) => setTimeout(resolve, 3000)) + + // Check if a second error is thrown + errorOccurred = false + + try { + await apiUsingOnResponse.post('http://httpstat.us/526?sleep=2800') + } catch (error) { + console.log(error) + if (error instanceof PlexusError) { + // if it's a PlexusError, it means this is the timeout error + return + } + errorOccurred = true + } + + expect(errorOccurred).toBe(false) + }) +}, 10000) + +test('Does retry work', async () => { + // const value = state(1) + const apiUsingOnResponse = api('', { + timeout: 1000, + throws: true, + retry: 3, + abortOnTimeout: true, + }) + // should retry 3 times + let loopCount = 0 + + try { await apiUsingOnResponse.post('http://httpstat.us/526?sleep=2800') } catch (error) { @@ -136,7 +169,7 @@ describe("Test the API's baseURL capabilities", () => { test('Can make a request to a sub-path', async () => { const res = await myApi2.post('maps') - expect(myApi2.config.headers['Content-Type']).toBe('application/json') + expect(myApi2.headers['Content-Type']).toBe('application/json') // console.log(JSON.stringify(res, null, 2)) expect(res?.status).toBeGreaterThan(0) }) @@ -154,7 +187,7 @@ describe("Test the API's baseURL capabilities", () => { const res = await myApi2.post('maps') - expect(myApi2.config.headers['X-Test']).toBe(intendedValue) + expect(myApi2.headers['X-Test']).toBe(intendedValue) // console.log(JSON.stringify(res, null, 2)) expect(res?.status).toBeGreaterThan(0) }) From 3968b92da8be5f3e8e5410a45b2d21f860e94334 Mon Sep 17 00:00:00 2001 From: Philippe Clesca Date: Wed, 25 Oct 2023 18:48:34 -0400 Subject: [PATCH 6/7] more progress, broken still --- packages/plexus-api/src/api.ts | 14 ++---- packages/plexus-api/src/request.ts | 69 +++++++++++++++++++++++++++--- packages/plexus-api/src/types.ts | 3 +- packages/plexus-api/src/utils.ts | 7 +++ tests/api.test.ts | 34 +++++++-------- 5 files changed, 90 insertions(+), 37 deletions(-) create mode 100644 packages/plexus-api/src/utils.ts diff --git a/packages/plexus-api/src/api.ts b/packages/plexus-api/src/api.ts index f9613ef..0f78846 100644 --- a/packages/plexus-api/src/api.ts +++ b/packages/plexus-api/src/api.ts @@ -4,7 +4,6 @@ import { ApiMethod, PlexusApiConfig, PlexusApiReq, - PlexusApiRes, PlexusApiSendOptions, PlexusApiFetchOptions, PlexusApiInstanceConfig, @@ -28,13 +27,6 @@ globalThis.Blob = Blob export type PlexusApi = ApiInstance const AuthTypes = ['bearer', 'basic', 'jwt'] as const -// type HeaderCache> = -// | [ -// CacheValue | Promise | undefined, -// (() => CacheValue | Promise | undefined) | undefined -// ] -// | [] - /** * An API instance is used to make requests to a server. Interact with this by using `api()` */ @@ -97,7 +89,7 @@ export class ApiInstance { // this.addToQueue(`${this.genKey('GET', path)}`, () => {}) let request: ApiRequest if (!this.requestMap.has(path)) { - request = new ApiRequest(this, path, { requestOptions: options }) + request = new ApiRequest(this, path, { defaultOptions: options }) this.requestMap.set(path, request) } else { request = this.requestMap.get(path) as ApiRequest @@ -193,7 +185,7 @@ export class ApiInstance { */ async post< ResponseType = any, - BodyType extends Record | string = {} + BodyType extends Record | string = {}, >( path: string, body: BodyType = {} as BodyType, @@ -346,7 +338,7 @@ export class ApiInstance { setHeaders< HeaderFunction extends () => | Record - | Promise> + | Promise>, >(inputFnOrObj: HeaderFunction | Record) { // if (!_headers) _internalStore._options.headers = {} if (this._internalStore.noFetch) return this diff --git a/packages/plexus-api/src/request.ts b/packages/plexus-api/src/request.ts index 53ede7e..a5114f6 100644 --- a/packages/plexus-api/src/request.ts +++ b/packages/plexus-api/src/request.ts @@ -1,21 +1,62 @@ import { PlexusError } from '@plexusjs/utils' import { ApiInstance } from './api' -import { PlexusApiConfig, PlexusApiRes, PlexusApiSendOptions } from './types' +import { + PlexusApiConfig, + PlexusApiReq, + PlexusApiRes, + PlexusApiSendOptions, +} from './types' +import { uuid } from './utils' export class ApiRequest { private attempts = 0 + private controllerMap: Map = new Map() constructor( public api: ApiInstance, public path: string, - public config: Partial<{ requestOptions: PlexusApiSendOptions }> & - PlexusApiConfig + public config: PlexusApiConfig ) {} - private retry(path: string, options: PlexusApiSendOptions) { - if (this.config.retry) { + public getRequestSchema( + method: PlexusApiReq['method'], + payload?: { + body?: BodyType + path?: string + } + ) { + const body = payload?.body ?? ({} as BodyType) + return { + path: this.path, + baseURL: this.api.config.baseURL, + options: this.api.config.options, + headers: this.config.headers, + body, + method, + } as PlexusApiReq + } + + /** + * Retry a request + * @param path The path to send the request to + * @param options The options to send with the request + * @returns undefined if the request can't be retried, otherwise a pending response + */ + private async retry( + path: string, + options: PlexusApiSendOptions + ) { + if (!!this.config.retry) { + console.log('retrying', this.attempts, this.config.retry) if (this.attempts < this.config.retry) { + return false this.attempts++ - return this.send(path, options) + this.config.onRetry?.( + this.attempts, + this.getRequestSchema(options.method, { + body: options.body, + }) + ) + return await this.send(path, options) } } } @@ -28,6 +69,7 @@ export class ApiRequest { path: string, options: PlexusApiSendOptions ): Promise> { + const requestId = uuid() const instanceHeaders = this.api.headers const headers = { @@ -55,6 +97,7 @@ export class ApiRequest { path.startsWith('/') || path?.length === 0 ? path : `/${path}` }` + // create a new abort controller and add it to the controller map const controller = new AbortController() const requestObject = { ...this.api.config.options, @@ -62,6 +105,8 @@ export class ApiRequest { headers, signal: controller.signal, } + this.controllerMap.set(requestId, controller) + // if we have a timeout set, call fetch and set a timeout. If the fetch takes longer than the timeout length, kill thee request and return a blank response if (this.config.timeout) { let to: any @@ -86,8 +131,13 @@ export class ApiRequest { if (raceResult) { res = raceResult } else { + // abort the request if (this.config.abortOnTimeout) controller.abort() + // if retry returns something (which means it's retrying), return it + const retrying = await this.retry(path, options) + if (!!this.config.retry && retrying) return retrying + // if we're throwing, throw an error if (this.config.throws) throw new PlexusError('Request timed out', { type: 'api' }) @@ -102,12 +152,17 @@ export class ApiRequest { res = await fetch(uri, requestObject) } } catch (e) { - this.retry(path, options) + // if retry returns something (which means it's retrying), return it + const retrying = await this.retry(path, options) + if (!!this.config.retry && retrying) return retrying // if silentFail is enabled, don't throw the error; Otherwise, throw an error if (!this.config.throws) { throw e } } + // we're successful, reset the retry counter + this.attempts = 0 + let data: ResponseDataType let rawData: string let blob: Blob diff --git a/packages/plexus-api/src/types.ts b/packages/plexus-api/src/types.ts index 8eca242..cfbb64d 100644 --- a/packages/plexus-api/src/types.ts +++ b/packages/plexus-api/src/types.ts @@ -11,6 +11,7 @@ export interface PlexusApiConfig { defaultOptions?: PlexusApiOptions timeout?: number retry?: number + onRetry?: (currentRetry: number, req: PlexusApiReq) => void abortOnTimeout?: boolean throws?: boolean onResponse?: (req: PlexusApiReq, res: PlexusApiRes) => void @@ -26,7 +27,7 @@ export interface PlexusApiInstanceConfig extends PlexusApiConfig { export interface PlexusApiReq { path: string baseURL: string - fullURL: string + // fullURL: string method: 'POST' | 'GET' | 'PUT' | 'DELETE' | 'PATCH' headers: Record body: BodyType diff --git a/packages/plexus-api/src/utils.ts b/packages/plexus-api/src/utils.ts new file mode 100644 index 0000000..18aa777 --- /dev/null +++ b/packages/plexus-api/src/utils.ts @@ -0,0 +1,7 @@ +export const uuid = () => { + return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { + const r = (Math.random() * 16) | 0 + const v = c == 'x' ? r : (r & 0x3) | 0x8 + return v.toString(16) + }) +} diff --git a/tests/api.test.ts b/tests/api.test.ts index 9d1fdfd..3da2d60 100644 --- a/tests/api.test.ts +++ b/tests/api.test.ts @@ -120,48 +120,46 @@ describe('Testing Api Function', () => { expect(errorOccurred).toBe(false) }) -}, 10000) - -test('Does retry work', async () => { + test('Does retry work', async () => { // const value = state(1) + // should retry 3 times + let loopCount = 0 const apiUsingOnResponse = api('', { - timeout: 1000, + timeout: 100, throws: true, retry: 3, abortOnTimeout: true, + onRetry(iteration) { + console.log('retrying', iteration) + loopCount = iteration + }, }) - // should retry 3 times - let loopCount = 0 - try { - await apiUsingOnResponse.post('http://httpstat.us/526?sleep=2800') + await apiUsingOnResponse.post('http://httpstat.us/526?sleep=200') } catch (error) { console.log(error) - errorOccurred = true } - expect(errorOccurred).toBe(true) - - // Wait for the sleep duration of the request endpoint - await new Promise((resolve) => setTimeout(resolve, 3000)) + // expect(errorOccurred).toBe(3) + expect(loopCount).toBe(3) // Check if a second error is thrown - errorOccurred = false + // errorOccurred = false try { - await apiUsingOnResponse.post('http://httpstat.us/526?sleep=2800') + await apiUsingOnResponse.post('http://httpstat.us/500') } catch (error) { console.log(error) if (error instanceof PlexusError) { // if it's a PlexusError, it means this is the timeout error return } - errorOccurred = true + // errorOccurred = true } - expect(errorOccurred).toBe(false) + expect(loopCount).toBe(3) }) -}, 10000) +}) describe("Test the API's baseURL capabilities", () => { const myApi2 = api('https://google.com').setHeaders({ 'Content-Type': 'application/json', From 5cdd96f77ee9e1b69bbbbc10a5e8d637b5abacc6 Mon Sep 17 00:00:00 2001 From: Philippe Date: Wed, 10 Apr 2024 04:50:58 -0400 Subject: [PATCH 7/7] Update .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index b81a736..251a60f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ __snapshots__ *.tsbuildinfo *.tgz *.log +.turbo \ No newline at end of file