diff --git a/src/api/DeputyAPI.ts b/src/api/DeputyAPI.ts index 6fc04fc6..5d38348f 100644 --- a/src/api/DeputyAPI.ts +++ b/src/api/DeputyAPI.ts @@ -1,4 +1,5 @@ import { ExpandedRevisionData } from './ExpandedRevisionData'; +import Requester from '../util/Requester'; /** * API communication class @@ -21,7 +22,7 @@ export default class DeputyAPI { */ async logout() { // TODO: Make logout API request - window.deputy.storage.setKV( 'api-token', null ); + await window.deputy.storage.setKV( 'api-token', null ); } /** @@ -45,7 +46,7 @@ export default class DeputyAPI { async getExpandedRevisionData( revisions: number[] ): Promise> { - return fetch( + return Requester.fetch( `https://zoomiebot.toolforge.org/bot/api/deputy/v1/revisions/${ mw.config.get( 'wgWikiID' ) }`, diff --git a/src/util/Requester.ts b/src/util/Requester.ts new file mode 100644 index 00000000..dd81dfed --- /dev/null +++ b/src/util/Requester.ts @@ -0,0 +1,85 @@ +import sleep from './sleep'; + +/** + * Handles requests that might get hit by a rate limit. Wraps around + * `fetch` and ensures that all users of the Requester only request + * a single time per 100 ms on top of the time it takes to load + * previous requests. Also runs on four "threads", allowing at + * least a certain level of asynchronicity. + * + * Particularly used when a multitude of requests have a chance to + * DoS a service. + */ +export default class Requester { + + /** + * Maximum number of requests to be processed simultaneously. + */ + static readonly maxThreads = 4; + /** + * Minimum amount of milliseconds to wait between each request. + */ + static readonly minTime = 100; + + /** + * Requests to be performed. Takes tuples containing a resolve-reject pair and arguments + * to be passed into the fetch function. + */ + static readonly fetchQueue: [ + [( data: Response ) => void, ( reason?: any ) => void], any[] + ][] = []; + /** + * Number of requests currently being processed. Must be lower than + * {@link maxThreads}. + */ + static fetchActive = 0; + + static readonly fetch: typeof window.fetch = ( ...args: any[] ) => { + let res, rej; + const fakePromise = new Promise( ( _res, _rej ) => { + res = _res; + rej = _rej; + } ); + Requester.fetchQueue.push( [ [ res, rej ], args ] ); + setTimeout( Requester.processFetch, 0 ); + return fakePromise; + }; + + /** + * Processes things in the fetchQueue. + */ + static async processFetch() { + if ( Requester.fetchActive >= Requester.maxThreads ) { + return; + } + Requester.fetchActive++; + + const next = Requester.fetchQueue.shift(); + if ( next ) { + + const data : Response | /* survivable error */ number | /* when caught */ void = + // eslint-disable-next-line prefer-spread + await ( fetch.apply( null, next[ 1 ] ) as Promise ) + .then( ( res ) => { + // Return false for survivable cases. In this case, we'll re-queue + // the request. + if ( res.status === 429 || res.status === 502 ) { + return res.status; + } else { + return res; + } + }, next[ 0 ][ 1 ] ); + + if ( data instanceof Response ) { + next[ 0 ][ 0 ]( data ); + } else if ( typeof data === 'number' ) { + Requester.fetchQueue.push( next ); + } + } + + await sleep( Requester.minTime ); + Requester.fetchActive--; + setTimeout( Requester.processFetch, 0 ); + } + +} diff --git a/src/util/sleep.ts b/src/util/sleep.ts new file mode 100644 index 00000000..301f49c8 --- /dev/null +++ b/src/util/sleep.ts @@ -0,0 +1,10 @@ +/** + * Sleep for an specified amount of time. + * + * @param ms Milliseconds to sleep for. + */ +export default async function sleep( ms: number ): Promise { + return new Promise( ( res ) => { + setTimeout( res, ms ); + } ); +}