Skip to content

Commit

Permalink
rate limiting for API requests
Browse files Browse the repository at this point in the history
  • Loading branch information
ChlodAlejandro committed Oct 18, 2022
1 parent 5cf6b07 commit 1cb72b9
Show file tree
Hide file tree
Showing 3 changed files with 98 additions and 2 deletions.
5 changes: 3 additions & 2 deletions src/api/DeputyAPI.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ExpandedRevisionData } from './ExpandedRevisionData';
import Requester from '../util/Requester';

/**
* API communication class
Expand All @@ -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 );
}

/**
Expand All @@ -45,7 +46,7 @@ export default class DeputyAPI {
async getExpandedRevisionData(
revisions: number[]
): Promise<Record<number, ExpandedRevisionData>> {
return fetch(
return Requester.fetch(
`https://zoomiebot.toolforge.org/bot/api/deputy/v1/revisions/${
mw.config.get( 'wgWikiID' )
}`,
Expand Down
85 changes: 85 additions & 0 deletions src/util/Requester.ts
Original file line number Diff line number Diff line change
@@ -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<Response>( ( _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<Response> )
.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 );
}

}
10 changes: 10 additions & 0 deletions src/util/sleep.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
return new Promise<void>( ( res ) => {
setTimeout( res, ms );
} );
}

0 comments on commit 1cb72b9

Please sign in to comment.