Skip to content

Commit

Permalink
feat(client): throttle requests
Browse files Browse the repository at this point in the history
The max amount of requests we can send per second ist defined by the customers tier (`7`, `10`, `30`).
This PR adds a client param `throttle` to set the max requests the client can sends per second. If not defined or set to `0`, nothing will be throttled.
  • Loading branch information
marcolink committed Oct 1, 2021
1 parent 0079545 commit 1d51536
Show file tree
Hide file tree
Showing 8 changed files with 224 additions and 135 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
271 changes: 138 additions & 133 deletions src/create-http-client.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import copy from 'fast-copy'
import qs from 'qs'
import type { AxiosStatic } from 'axios'
import type { AxiosInstance, CreateHttpClientParams } from './types'
import type {AxiosStatic} from 'axios'
import rateLimitThrottle from "./rate-limit-throttle";
import type {AxiosInstance, CreateHttpClientParams} from './types'

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

import { isNode, getNodeVersion } from './utils'
import {isNode, getNodeVersion} from './utils'

// Matches 'sub.host:port' or 'host:port' and extracts hostname and port
// Also enforces toplevel domain specified, no spaces and no protocol
Expand All @@ -20,136 +21,140 @@ const HOST_REGEX = /^(?!\w+:\/\/)([^\s:]+\.?[^\s:]+)(?::(\d+))?(?!:)$/
* @return {ContentfulAxiosInstance} Initialized axios instance
*/
export default function createHttpClient(
axios: AxiosStatic,
options: CreateHttpClientParams
axios: AxiosStatic,
options: CreateHttpClientParams
): AxiosInstance {
const defaultConfig = {
insecure: false as const,
retryOnError: true as const,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
logHandler: (level: string, data: any): void => {
if (level === 'error' && data) {
const title = [data.name, data.message].filter((a) => a).join(' - ')
console.error(`[error] ${title}`)
console.error(data)
return
}
console.log(`[${level}] ${data}`)
},
// Passed to axios
headers: {} as Record<string, unknown>,
httpAgent: false as const,
httpsAgent: false as const,
timeout: 30000,
proxy: false as const,
basePath: '',
adapter: undefined,
maxContentLength: 1073741824, // 1GB
maxBodyLength: 1073741824, // 1GB
}
const config = {
...defaultConfig,
...options,
}

if (!config.accessToken) {
const missingAccessTokenError = new TypeError('Expected parameter accessToken')
config.logHandler('error', missingAccessTokenError)
throw missingAccessTokenError
}

// Construct axios baseURL option
const protocol = config.insecure ? 'http' : 'https'
const space = config.space ? `${config.space}/` : ''
let hostname = config.defaultHostname
let port: number | string = config.insecure ? 80 : 443
if (config.host && HOST_REGEX.test(config.host)) {
const parsed = config.host.split(':')
if (parsed.length === 2) {
;[hostname, port] = parsed
} else {
hostname = parsed[0]
const defaultConfig = {
insecure: false as const,
retryOnError: true as const,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
logHandler: (level: string, data: any): void => {
if (level === 'error' && data) {
const title = [data.name, data.message].filter((a) => a).join(' - ')
console.error(`[error] ${title}`)
console.error(data)
return
}
console.log(`[${level}] ${data}`)
},
// Passed to axios
headers: {} as Record<string, unknown>,
httpAgent: false as const,
httpsAgent: false as const,
timeout: 30000,
throttle: 0,
proxy: false as const,
basePath: '',
adapter: undefined,
maxContentLength: 1073741824, // 1GB
maxBodyLength: 1073741824, // 1GB
}
}

// Ensure that basePath does start but not end with a slash
if (config.basePath) {
config.basePath = `/${config.basePath.split('/').filter(Boolean).join('/')}`
}

const baseURL =
options.baseURL || `${protocol}://${hostname}:${port}${config.basePath}/spaces/${space}`

if (!config.headers.Authorization && typeof config.accessToken !== 'function') {
config.headers.Authorization = 'Bearer ' + config.accessToken
}

// Set these headers only for node because browsers don't like it when you
// override user-agent or accept-encoding.
// The SDKs should set their own X-Contentful-User-Agent.
if (isNode()) {
config.headers['user-agent'] = 'node.js/' + getNodeVersion()
config.headers['Accept-Encoding'] = 'gzip'
}

const axiosOptions = {
// Axios
baseURL,
headers: config.headers,
httpAgent: config.httpAgent,
httpsAgent: config.httpsAgent,
paramsSerializer: qs.stringify,
proxy: config.proxy,
timeout: config.timeout,
adapter: config.adapter,
maxContentLength: config.maxContentLength,
maxBodyLength: config.maxBodyLength,
// Contentful
logHandler: config.logHandler,
responseLogger: config.responseLogger,
requestLogger: config.requestLogger,
retryOnError: config.retryOnError,
}
const instance = axios.create(axiosOptions) as AxiosInstance
instance.httpClientParams = options

/**
* Creates a new axios instance with the same default base parameters as the
* current one, and with any overrides passed to the newParams object
* This is useful as the SDKs use dependency injection to get the axios library
* and the version of the library comes from different places depending
* on whether it's a browser build or a node.js build.
* @private
* @param {CreateHttpClientParams} httpClientParams - Initialization parameters for the HTTP client
* @return {ContentfulAxiosInstance} Initialized axios instance
*/
instance.cloneWithNewParams = function (
newParams: Partial<CreateHttpClientParams>
): AxiosInstance {
return createHttpClient(axios, {
...copy(options),
...newParams,
})
}

/**
* Apply interceptors.
* Please note that the order of interceptors is important
*/

if (config.onBeforeRequest) {
instance.interceptors.request.use(config.onBeforeRequest)
}

if (typeof config.accessToken === 'function') {
asyncToken(instance, config.accessToken)
}

rateLimit(instance, config.retryLimit)

if (config.onError) {
instance.interceptors.response.use((response) => response, config.onError)
}

return instance
const config = {
...defaultConfig,
...options,
}

if (!config.accessToken) {
const missingAccessTokenError = new TypeError('Expected parameter accessToken')
config.logHandler('error', missingAccessTokenError)
throw missingAccessTokenError
}

// Construct axios baseURL option
const protocol = config.insecure ? 'http' : 'https'
const space = config.space ? `${config.space}/` : ''
let hostname = config.defaultHostname
let port: number | string = config.insecure ? 80 : 443
if (config.host && HOST_REGEX.test(config.host)) {
const parsed = config.host.split(':')
if (parsed.length === 2) {
;[hostname, port] = parsed
} else {
hostname = parsed[0]
}
}

// Ensure that basePath does start but not end with a slash
if (config.basePath) {
config.basePath = `/${config.basePath.split('/').filter(Boolean).join('/')}`
}

const baseURL =
options.baseURL || `${protocol}://${hostname}:${port}${config.basePath}/spaces/${space}`

if (!config.headers.Authorization && typeof config.accessToken !== 'function') {
config.headers.Authorization = 'Bearer ' + config.accessToken
}

// Set these headers only for node because browsers don't like it when you
// override user-agent or accept-encoding.
// The SDKs should set their own X-Contentful-User-Agent.
if (isNode()) {
config.headers['user-agent'] = 'node.js/' + getNodeVersion()
config.headers['Accept-Encoding'] = 'gzip'
}

const axiosOptions = {
// Axios
baseURL,
headers: config.headers,
httpAgent: config.httpAgent,
httpsAgent: config.httpsAgent,
paramsSerializer: qs.stringify,
proxy: config.proxy,
timeout: config.timeout,
adapter: config.adapter,
maxContentLength: config.maxContentLength,
maxBodyLength: config.maxBodyLength,
// Contentful
logHandler: config.logHandler,
responseLogger: config.responseLogger,
requestLogger: config.requestLogger,
retryOnError: config.retryOnError,
}
const instance = axios.create(axiosOptions) as AxiosInstance
instance.httpClientParams = options

/**
* Creates a new axios instance with the same default base parameters as the
* current one, and with any overrides passed to the newParams object
* This is useful as the SDKs use dependency injection to get the axios library
* and the version of the library comes from different places depending
* on whether it's a browser build or a node.js build.
* @private
* @param {CreateHttpClientParams} httpClientParams - Initialization parameters for the HTTP client
* @return {ContentfulAxiosInstance} Initialized axios instance
*/
instance.cloneWithNewParams = function (
newParams: Partial<CreateHttpClientParams>
): AxiosInstance {
return createHttpClient(axios, {
...copy(options),
...newParams,
})
}

/**
* Apply interceptors.
* Please note that the order of interceptors is important
*/

if (config.onBeforeRequest) {
instance.interceptors.request.use(config.onBeforeRequest)
}

if (typeof config.accessToken === 'function') {
asyncToken(instance, config.accessToken)
}

if (config.throttle > 0) {
rateLimitThrottle(instance, config.throttle);
}
rateLimit(instance, config.retryLimit)

if (config.onError) {
instance.interceptors.response.use((response) => response, config.onError)
}

return instance
}
15 changes: 15 additions & 0 deletions src/rate-limit-throttle.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import pThrottle from "p-throttle";
import {AxiosInstance} from "./types";

export default (axiosInstance: AxiosInstance, limit = 7) => {
const throttle = pThrottle({
limit,
interval: 1000
});

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

return () => axiosInstance.interceptors.request.eject(interceptorId);
}
2 changes: 1 addition & 1 deletion src/rate-limit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,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
7 changes: 7 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,4 +98,11 @@ 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
*/
throttle?: 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
Loading

0 comments on commit 1d51536

Please sign in to comment.