Skip to content

Commit

Permalink
Merge pull request #214 from contentful/feat/throttle-requests
Browse files Browse the repository at this point in the history
feat(client): throttle requests
  • Loading branch information
marcolink authored Oct 4, 2021
2 parents 0079545 + 7639a97 commit ff4902a
Show file tree
Hide file tree
Showing 8 changed files with 213 additions and 8 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@
"dependencies": {
"fast-copy": "^2.1.0",
"lodash": "^4.17.21",
"p-throttle": "^4.1.1",
"qs": "^6.9.4"
},
"devDependencies": {
Expand Down
9 changes: 7 additions & 2 deletions src/create-http-client.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import copy from 'fast-copy'
import qs from 'qs'
import type { AxiosStatic } from 'axios'
import rateLimitThrottle from './rate-limit-throttle'
import type { AxiosInstance, CreateHttpClientParams } from './types'

import rateLimit from './rate-limit'
import rateLimitRetry from './rate-limit'
import asyncToken from './async-token'

import { isNode, getNodeVersion } from './utils'
Expand Down Expand Up @@ -41,6 +42,7 @@ export default function createHttpClient(
httpAgent: false as const,
httpsAgent: false as const,
timeout: 30000,
throttle: 0,
proxy: false as const,
basePath: '',
adapter: undefined,
Expand Down Expand Up @@ -145,7 +147,10 @@ export default function createHttpClient(
asyncToken(instance, config.accessToken)
}

rateLimit(instance, config.retryLimit)
if (config.throttle) {
rateLimitThrottle(instance, config.throttle)
}
rateLimitRetry(instance, config.retryLimit)

if (config.onError) {
instance.interceptors.response.use((response) => response, config.onError)
Expand Down
75 changes: 75 additions & 0 deletions src/rate-limit-throttle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { isString, noop } from 'lodash'
import pThrottle from 'p-throttle'
import { AxiosInstance } from './types'

type ThrottleType = 'auto' | `${number}%`

const PERCENTAGE_REGEX = /(?<value>\d+)(%)/

function calculateLimit(type: ThrottleType, max = 7) {
let limit = max

if (PERCENTAGE_REGEX.test(type)) {
const groups = type.match(PERCENTAGE_REGEX)?.groups
if (groups && groups.value) {
const percentage = parseInt(groups.value) / 100
limit = Math.round(max * percentage)
}
}
return Math.min(30, Math.max(1, limit))
}

function createThrottle(limit: number, logger: (...args: unknown[]) => void) {
logger('info', `Throttle request to ${limit}/s`)
return pThrottle({
limit,
interval: 1000,
strict: false,
})
}

export default (axiosInstance: AxiosInstance, type: ThrottleType | number = 'auto') => {
const { logHandler = noop } = axiosInstance.defaults
let limit = isString(type) ? calculateLimit(type) : calculateLimit('auto', type)
let throttle = createThrottle(limit, logHandler)
let isCalculated = false

let requestInterceptorId = axiosInstance.interceptors.request.use((config) => {
return throttle<[], typeof config>(() => config)()
}, Promise.reject)

const responseInterceptorId = axiosInstance.interceptors.response.use((response) => {
if (
!isCalculated &&
isString(type) &&
(type === 'auto' || PERCENTAGE_REGEX.test(type)) &&
response.headers &&
response.headers['x-contentful-ratelimit-second-limit']
) {
const rawLimit = parseInt(response.headers['x-contentful-ratelimit-second-limit'])
const nextLimit = calculateLimit(type, rawLimit)

if (nextLimit !== limit) {
if (requestInterceptorId) {
axiosInstance.interceptors.request.eject(requestInterceptorId)
}

limit = nextLimit

throttle = createThrottle(nextLimit, logHandler)
requestInterceptorId = axiosInstance.interceptors.request.use((config) => {
return throttle<[], typeof config>(() => config)()
}, Promise.reject)
}

isCalculated = true
}

return response
}, Promise.reject)

return () => {
axiosInstance.interceptors.request.eject(requestInterceptorId)
axiosInstance.interceptors.response.eject(responseInterceptorId)
}
}
7 changes: 2 additions & 5 deletions src/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
import { noop } from 'lodash'
import type { AxiosInstance } from './types'

const attempts: Record<string, number> = {}
let networkErrorAttempts = 0

function noop(): undefined {
return undefined
}

const delay = (ms: number): Promise<void> =>
new Promise((resolve) => {
setTimeout(resolve, ms)
Expand Down Expand Up @@ -44,7 +41,7 @@ export default function rateLimit(instance: AxiosInstance, maxRetry = 5): void {
let retryErrorType = null
let wait = 0

// Errors without response did not recieve anything from the server
// Errors without response did not receive anything from the server
if (!response) {
retryErrorType = 'Connection'
networkErrorAttempts++
Expand Down
8 changes: 8 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,12 @@ export type CreateHttpClientParams = {
* @default 1073741824 i.e 1GB
*/
maxBodyLength?: number

/**
* Optional maximum number of requests per second (rate-limit)
* @desc should represent the max of your current plan's rate limit
* @default 0 = no throttling
* @param 1-30 (fixed number of limit), 'auto' (calculated limit based on current tier), '0%' - '100%' (calculated % limit based on tier)
*/
throttle?: 'auto' | `${number}%` | number
}
1 change: 1 addition & 0 deletions test/unit/create-http-client-test.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import axios, { AxiosAdapter } from 'axios'
import MockAdapter from 'axios-mock-adapter'

jest.mock('../../src/rate-limit', () => jest.fn())
jest.mock('../../src/rate-limit-throttle', () => jest.fn())

const mockedAxios = axios as jest.Mocked<typeof axios>

Expand Down
2 changes: 1 addition & 1 deletion test/unit/rate-limit-test.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import axios from 'axios'
import MockAdapter from 'axios-mock-adapter'

import createHttpClient from '../../src/create-http-client'
import { CreateHttpClientParams } from '../../src/types'
import { CreateHttpClientParams } from '../../src'

const logHandlerStub = jest.fn()

Expand Down
118 changes: 118 additions & 0 deletions test/unit/rate-limit-throttle.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/* eslint @typescript-eslint/ban-ts-comment: 0 */

import axios from 'axios'
import MockAdapter from 'axios-mock-adapter'
import { AxiosInstance } from '../../src'
import createHttpClient from '../../src/create-http-client'

const logHandlerStub = jest.fn()

function wait(ms = 1000) {
return new Promise((resolve) => {
setTimeout(resolve, ms)
})
}

function executeCalls(client: AxiosInstance, callsCount: number) {
const requests = []
for (let i = 0; i < callsCount; i++) {
requests.push(client.get('/throttled-call'))
}
return requests
}

describe('throttle to rate limit axios interceptor', () => {
let mock = new MockAdapter(axios)

beforeEach(() => {
mock = new MockAdapter(axios)
mock.onGet('/throttled-call').reply(200, null, { 'x-contentful-ratelimit-second-limit': 10 })
})

afterEach(() => {
logHandlerStub.mockReset()
})

async function expectCallsExecutedWithin(
client: AxiosInstance,
callsCount: number,
duration: number
) {
// initial call to potentially update throttling settings
client.get('/throttled-call')
await wait(1000)

const calls = executeCalls(client, callsCount)

const start = Date.now()
await Promise.all(calls)
expect(mock.history.get).toHaveLength(callsCount + 1)

expect(Date.now() - start).toBeLessThanOrEqual(duration)
}

function expectLogHandlerHasBeenCalled(limit: number, callCount: number) {
expect(logHandlerStub).toBeCalledTimes(callCount)
expect(logHandlerStub.mock.calls[callCount - 1][0]).toEqual('info')
expect(logHandlerStub.mock.calls[callCount - 1][1]).toContain(`Throttle request to ${limit}/s`)
}

it('fires all requests directly', async () => {
const client = createHttpClient(axios, {
accessToken: 'token',
logHandler: logHandlerStub,
throttle: 0,
})
await expectCallsExecutedWithin(client, 20, 100)
expect(logHandlerStub).not.toHaveBeenCalled()
})

it('fires limited requests per second', async () => {
const client = createHttpClient(axios, {
accessToken: 'token',
logHandler: logHandlerStub,
throttle: 3,
})
await expectCallsExecutedWithin(client, 3, 1010)
expectLogHandlerHasBeenCalled(3, 1)
})

it('invalid argument defaults to 7/s', async () => {
const client = createHttpClient(axios, {
accessToken: 'token',
logHandler: logHandlerStub,
// @ts-ignore
throttle: 'invalid',
})
await expectCallsExecutedWithin(client, 7, 1010)
expectLogHandlerHasBeenCalled(7, 1)
})

it('calculate limit based on response header', async () => {
const client = createHttpClient(axios, {
accessToken: 'token',
logHandler: logHandlerStub,
throttle: 'auto',
})
await expectCallsExecutedWithin(client, 10, 1010)
expectLogHandlerHasBeenCalled(10, 2)
})

it.each([
{ throttle: '30%', limit: 3, duration: 1010 },
{ throttle: '50%', limit: 5, duration: 1010 },
{ throttle: '70%', limit: 7, duration: 1010 },
])(
'calculate $throttle limit based on response header',
async ({ throttle, limit, duration }) => {
const client = createHttpClient(axios, {
accessToken: 'token',
logHandler: logHandlerStub,
// @ts-ignore
throttle: throttle,
})
await expectCallsExecutedWithin(client, limit, duration)
expectLogHandlerHasBeenCalled(limit, 2)
}
)
})

0 comments on commit ff4902a

Please sign in to comment.