0
\$\begingroup\$

I'm using multiple APIs with different rate limits and also do requests to non-rate-limited websites and I didn't want to handle multiple axios instances for each type. There are quite a few limiters out there, but I didn't find one that is granular enough to only limit certain websites, but not others.

Known limitations:

  • when you stack routes, e.g. localhost/someResource and localhost, then only the limit of localhost applies and localhost/someResource will be ignored. I don't think it's a big issue since I don't know any API that is this granular with its rate limiting.

Here is the code:

import Axios, { AxiosRequestConfig, AxiosResponse } from "axios";


/* Constants */
const RATE_LIMIT_DEFAULT:RateLimit = {
  interval: 1000,
  maxRequests: 10
}
const QUEUE_DELAY = 10 //ms

/* Interfaces and Classes */
interface QueueObject {
  priority:number,
  key:number
}

class RequestQueue extends Array<QueueObject> {
  push(...requests:QueueObject[]) {
    super.push(...requests)
    this.sort(this.comparator)
    for (const item of this) {
      console.log(item.priority, item.key)
    }
    return this.length
  }

  isEmpty() {
    return this.length === 0
  }

  peek() {
    return this[this.length-1]
  }

  private comparator (a:QueueObject, b:QueueObject) {
    if (a.priority === b.priority) {
      return a.key - b.key
    }

    return  a.priority - b.priority
  }
}

interface RouteData {
    pattern:string,
    queue:RequestQueue,
    intervalCounter:number,
    intervalBegin:number
}

function waitFor(ms:number) {
  return new Promise((resolve, reject) => setTimeout(() => resolve('done'), ms))
}

export interface RateLimit {
  interval:number,
  maxRequests:number
}

export class RateLimiter extends Map<string, RateLimit> {
  private static instance:RateLimiter
  private routes:Map<string, RouteData>

  private constructor() {
    super()
    this.routes = new Map<string, RouteData>()
    Axios.interceptors.request.use(this.requestInteceptor)
    Axios.interceptors.response.use(this.responseInterceptor)
  }

  async send (route:string, key:number) {
    const params = <RouteData> this.routes.get(route)
    const limit = <RateLimit> this.get(route)

    while (true) {
      if (Date.now() - params.intervalBegin > limit.interval) {
        params.intervalCounter = 0
        params.intervalBegin = Date.now()
      }

      if (params.intervalCounter >= limit.maxRequests) {
        // to many requests, wait for the next period
        const minTimeout = (params.intervalBegin+limit.interval) - Date.now()
        await waitFor(minTimeout)
        continue
      }

      if (key !== params.queue.peek().key) {
        // there are other, more important requests
        await waitFor(QUEUE_DELAY)
        continue
      }
      // there is still requests left and this request is next
      params.intervalCounter++
      params.queue.pop()

      break
    }
  }

  enqueue(route:string, priority:number = 1) {
    const params = <RouteData> this.routes.get(route)
    const key = Math.floor(1000000000 + Math.random() * 999999999);
    params.queue.push({priority:priority, key:key})

    return key
  }

  async requestInteceptor(config:AxiosRequestConfig) {
    if (!config.url) {
      return config
    }

    const that = RateLimiter.getInstance()

    const match = that.routePattern.exec(config.url)
    if(match) {
      let priority = 1
      if (config.params && config.params.hasOwnProperty('RateLimitPriority')) {
        priority = config.params.RateLimitPriority
        delete config.params.RateLimitPriority
      }

      const route = match[0]
      console.log('Limiting the rate of ' + config.url)

      const key = that.enqueue(route, priority)
      await that.send(route, key)
      console.log(new Date(Date.now()).toTimeString(), 'Sending: ', config.url)
    }

    return config
  }

  async responseInterceptor(response:AxiosResponse) {
    // reserved, potentially adjust rate limit dynamically
    // through x-ratelimit header
    return response
  }

  set (route:string, limit:RateLimit = RATE_LIMIT_DEFAULT) {
    super.set(route, limit)
    const routeData:RouteData = {
      pattern: route.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&'),
      queue: new RequestQueue(),
      intervalCounter: 0,
      intervalBegin: Date.now()
    }
    this.routes.set(route, routeData)

    return this
  }

  clear () {
    super.clear()
    this.routes = new Map<string, RouteData>()

    return this
  }

  delete(route:string) {
    if (super.delete(route)) {
      this.routes.delete(route)
      return true
    } else {
      return false
    }
  }

  get routePattern () {
    return new RegExp([...this.routes.values()].map(val => val.pattern).join('|'))
  }

  static getInstance() {
    if (!RateLimiter.instance) {
        RateLimiter.instance = new RateLimiter()
    }
    return RateLimiter.instance
  }
}

Here is a small example usage:

import { RateLimiter } from './datastructure/RateLimiter'
import Axios from 'axios'

const limiter = RateLimiter.getInstance()
// custom rate limit
limiter.set('google.com', {
  maxRequests: 2,
  interval: 1000
})
// defult rate limit: 10 requests per second
limiter.set('google.de')

//pile up some work
for (let idx = 0; idx < 20; ++idx) {
  Axios.get('https://google.com')
    .then(result => console.log(new Date(Date.now()).toTimeString(), result.status, 'google.com'))
    .catch(err => console.error(err))
}
//this call will not be rate limited and pass right through
Axios.get('https://google.co.uk')
  .then(result => console.log(new Date(Date.now()).toTimeString(), result.status, 'google.co.uk'))
  .catch(err => console.error(err))

// really important request, send ASAP
// Note: 'RateLimitPriority' will be consumed by the interceptor
Axios.get('https://google.com',{params:{ 'RateLimitPriority': 100 }})
  .then(result => console.log(new Date(Date.now()).toTimeString(), result.status, 'important google.com'))
  .catch(err => console.error(err))
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Browse other questions tagged or ask your own question.