Skip to content

Commit 3a91322

Browse files
committed
fix: rate limit write function further
1 parent 29e096b commit 3a91322

File tree

10 files changed

+121
-93
lines changed

10 files changed

+121
-93
lines changed

lib/definitions/rate-limit.js

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/**
2+
* Default exponential backoff configuration for retries.
3+
*/
4+
const RETRY_CONF = {retries: 3, factor: 2, minTimeout: 1000};
5+
6+
/**
7+
* Rate limit per API endpoints.
8+
*
9+
* See {@link https://developer.github.com/v3/search/#rate-limit|Search API rate limit}.
10+
* See {@link https://developer.github.com/v3/#rate-limiting|Rate limiting}.
11+
*/
12+
const RATE_LIMITS = {
13+
search: ((60 * 1000) / 30) * 1.1, // 30 calls per minutes => 1 call every 2s + 10% safety margin
14+
core: {
15+
read: ((60 * 60 * 1000) / 5000) * 1.1, // 5000 calls per hour => 1 call per 720ms + 10% safety margin
16+
write: 3000, // 1 call every 3 seconds
17+
},
18+
};
19+
20+
/**
21+
* Global rate limit to prevent abuse.
22+
*
23+
* See {@link https://developer.github.com/v3/guides/best-practices-for-integrators/#dealing-with-abuse-rate-limits|Dealing with abuse rate limits}
24+
*/
25+
const GLOBAL_RATE_LIMIT = 1000;
26+
27+
module.exports = {RETRY_CONF, RATE_LIMITS, GLOBAL_RATE_LIMIT};

lib/get-client.js

Lines changed: 46 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,14 @@
11
const url = require('url');
2-
const {memoize} = require('lodash');
2+
const {memoize, get} = require('lodash');
33
const Octokit = require('@octokit/rest');
44
const pRetry = require('p-retry');
55
const Bottleneck = require('bottleneck');
66
const urljoin = require('url-join');
77
const HttpProxyAgent = require('http-proxy-agent');
88
const HttpsProxyAgent = require('https-proxy-agent');
99

10-
/**
11-
* Default exponential backoff configuration for retries.
12-
*/
13-
const DEFAULT_RETRY = {retries: 3, factor: 2, minTimeout: 1000};
14-
15-
/**
16-
* Rate limit per API endpoints.
17-
*
18-
* See {@link https://developer.github.com/v3/search/#rate-limit|Search API rate limit}.
19-
* See {@link https://developer.github.com/v3/#rate-limiting|Rate limiting}.
20-
*/
21-
const RATE_LIMITS = {
22-
search: (60 * 1000) / 30, // 30 calls per minutes => 1 call per 2s
23-
core: (60 * 60 * 1000) / 5000, // 5000 calls per hour => 1 call per 720ms
24-
};
25-
26-
/**
27-
* Global rate limit to prevent abuse.
28-
*
29-
* See {@link https://developer.github.com/v3/guides/best-practices-for-integrators/#dealing-with-abuse-rate-limits|Dealing with abuse rate limits}
30-
*/
31-
const GLOBAL_RATE_LIMIT = 1000;
10+
const GH_ROUTES = require('@octokit/rest/lib/routes');
11+
const {RETRY_CONF, RATE_LIMITS, GLOBAL_RATE_LIMIT} = require('./definitions/rate-limit');
3212

3313
/**
3414
* Http error codes for which to not retry.
@@ -41,35 +21,66 @@ const SKIP_RETRY_CODES = [400, 401, 403];
4121
* @param {Array} rate The rate limit group.
4222
* @param {String} limit The rate limits per API endpoints.
4323
* @param {Bottleneck} globalThrottler The global throttler.
24+
*
4425
* @return {Bottleneck} The throller function for the given rate limit group.
4526
*/
46-
const getThrottler = memoize((rate, limit, globalThrottler) =>
47-
new Bottleneck({minTime: limit[rate]}).chain(globalThrottler)
27+
const getThrottler = memoize((rate, globalThrottler) =>
28+
new Bottleneck({minTime: get(RATE_LIMITS, rate)}).chain(globalThrottler)
4829
);
4930

31+
/**
32+
* Determine if a call to a client function will trigger a read (`GET`) or a write (`POST`, `PATCH`, etc...) request.
33+
*
34+
* @param {String} endpoint The client API enpoint (for example the endpoint for a call to `github.repos.get` is `repos`).
35+
* @param {String} command The client API command (for example the command for a call to `github.repos.get` is `get`).
36+
*
37+
* @return {String} `write` or `read` if there is rate limit configuration for this `endpoint` and `command`, `undefined` otherwise.
38+
*/
39+
const getAccess = (endpoint, command) => {
40+
const method = GH_ROUTES[endpoint] && GH_ROUTES[endpoint][command] && GH_ROUTES[endpoint][command].method;
41+
const access = method && method === 'GET' ? 'read' : 'write';
42+
return RATE_LIMITS[endpoint][access] && access;
43+
};
44+
45+
/**
46+
* Get the limiter identifier associated with a client API call.
47+
*
48+
* @param {String} endpoint The client API enpoint (for example the endpoint for a call to `github.repos.get` is `repos`).
49+
* @param {String} command The client API command (for example the command for a call to `github.repos.get` is `get`).
50+
*
51+
* @return {String} A string identifying the limiter to use for this `endpoint` and `command` (e.g. `search` or `core.write`).
52+
*/
53+
const getLimitKey = (endpoint, command) => {
54+
return endpoint
55+
? [endpoint, RATE_LIMITS[endpoint] && getAccess(endpoint, command)].filter(Boolean).join('.')
56+
: RATE_LIMITS[command]
57+
? command
58+
: 'core';
59+
};
60+
5061
/**
5162
* Create a`handler` for a `Proxy` wrapping an Octokit instance to:
5263
* - Recursively wrap the child objects of the Octokit instance in a `Proxy`
5364
* - Throttle and retry the Octokit instance functions
5465
*
55-
* @param {Object} retry The configuration to pass to `p-retry`.
56-
* @param {Array} limit The rate limits per API endpoints.
5766
* @param {Throttler} globalThrottler The throller function for the global rate limit.
58-
* @param {String} endpoint The API endpoint to handle.
67+
* @param {String} limitKey The key to find the limit rate for the API endpoint and method.
68+
*
5969
* @return {Function} The `handler` for a `Proxy` wrapping an Octokit instance.
6070
*/
61-
const handler = (retry, limit, globalThrottler, endpoint) => ({
71+
const handler = (globalThrottler, limitKey) => ({
6272
/**
6373
* If the target has the property as own, determine the rate limit based on the property name and recursively wrap the value in a `Proxy`. Otherwise returns the property value.
6474
*
6575
* @param {Object} target The target object.
6676
* @param {String} name The name of the property to get.
6777
* @param {Any} receiver The `Proxy` object.
78+
*
6879
* @return {Any} The property value or a `Proxy` of the property value.
6980
*/
7081
get: (target, name, receiver) =>
7182
Reflect.apply(Object.prototype.hasOwnProperty, target, [name])
72-
? new Proxy(target[name], handler(retry, limit, globalThrottler, endpoint || name))
83+
? new Proxy(target[name], handler(globalThrottler, getLimitKey(limitKey, name)))
7384
: Reflect.get(target, name, receiver),
7485

7586
/**
@@ -78,11 +89,11 @@ const handler = (retry, limit, globalThrottler, endpoint) => ({
7889
* @param {Function} func The target function.
7990
* @param {Any} that The this argument for the call.
8091
* @param {Array} args The list of arguments for the call.
92+
*
8193
* @return {Promise<Any>} The result of the function called.
8294
*/
8395
apply: (func, that, args) => {
84-
const throttler = getThrottler(limit[endpoint] ? endpoint : 'core', limit, globalThrottler);
85-
96+
const throttler = getThrottler(limitKey, globalThrottler);
8697
return pRetry(async () => {
8798
try {
8899
return await throttler.wrap(func)(...args);
@@ -92,19 +103,11 @@ const handler = (retry, limit, globalThrottler, endpoint) => ({
92103
}
93104
throw err;
94105
}
95-
}, retry);
106+
}, RETRY_CONF);
96107
},
97108
});
98109

99-
module.exports = ({
100-
githubToken,
101-
githubUrl,
102-
githubApiPathPrefix,
103-
proxy,
104-
retry = DEFAULT_RETRY,
105-
limit = RATE_LIMITS,
106-
globalLimit = GLOBAL_RATE_LIMIT,
107-
}) => {
110+
module.exports = ({githubToken, githubUrl, githubApiPathPrefix, proxy} = {}) => {
108111
const baseUrl = githubUrl && urljoin(githubUrl, githubApiPathPrefix);
109112
const github = new Octokit({
110113
baseUrl,
@@ -115,5 +118,5 @@ module.exports = ({
115118
: undefined,
116119
});
117120
github.authenticate({type: 'token', token: githubToken});
118-
return new Proxy(github, handler(retry, limit, new Bottleneck({minTime: globalLimit})));
121+
return new Proxy(github, handler(new Bottleneck({minTime: GLOBAL_RATE_LIMIT})));
119122
};

test/fail.test.js

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,13 @@ import {stub} from 'sinon';
55
import proxyquire from 'proxyquire';
66
import SemanticReleaseError from '@semantic-release/error';
77
import ISSUE_ID from '../lib/definitions/sr-issue-id';
8-
import getClient from '../lib/get-client';
98
import {authenticate} from './helpers/mock-github';
9+
import rateLimit from './helpers/rate-limit';
1010

1111
/* eslint camelcase: ["error", {properties: "never"}] */
1212

1313
const fail = proxyquire('../lib/fail', {
14-
'./get-client': conf =>
15-
getClient({
16-
...conf,
17-
...{retry: {retries: 3, factor: 1, minTimeout: 1, maxTimeout: 1}, limit: {search: 1, core: 1}, globalLimit: 1},
18-
}),
14+
'./get-client': proxyquire('../lib/get-client', {'./definitions/rate-limit': rateLimit}),
1915
});
2016

2117
// Save the current process.env

test/find-sr-issue.test.js

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2,22 +2,16 @@ import {escape} from 'querystring';
22
import test from 'ava';
33
import nock from 'nock';
44
import {stub} from 'sinon';
5+
import proxyquire from 'proxyquire';
56
import ISSUE_ID from '../lib/definitions/sr-issue-id';
67
import findSRIssues from '../lib/find-sr-issues';
7-
import getClient from '../lib/get-client';
88
import {authenticate} from './helpers/mock-github';
9-
10-
/* eslint camelcase: ["error", {properties: "never"}] */
9+
import rateLimit from './helpers/rate-limit';
1110

1211
// Save the current process.env
1312
const envBackup = Object.assign({}, process.env);
1413
const githubToken = 'github_token';
15-
const client = getClient({
16-
githubToken,
17-
retry: {retries: 3, factor: 2, minTimeout: 1, maxTimeout: 1},
18-
globalLimit: 1,
19-
limit: {search: 1, core: 1},
20-
});
14+
const client = proxyquire('../lib/get-client', {'./definitions/rate-limit': rateLimit})({githubToken});
2115

2216
test.beforeEach(t => {
2317
// Delete env variables in case they are on the machine running the tests

test/get-client.test.js

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ import {stub, spy} from 'sinon';
99
import proxyquire from 'proxyquire';
1010
import Proxy from 'proxy';
1111
import serverDestroy from 'server-destroy';
12-
import getClient from '../lib/get-client';
12+
import rateLimit from './helpers/rate-limit';
13+
14+
const getClient = proxyquire('../lib/get-client', {'./definitions/rate-limit': rateLimit});
1315

1416
test.serial('Use a http proxy', async t => {
1517
const server = http.createServer();
@@ -33,7 +35,6 @@ test.serial('Use a http proxy', async t => {
3335
githubUrl: `http://localhost:${serverPort}`,
3436
githubApiPathPrefix: '',
3537
proxy: `http://localhost:${proxyPort}`,
36-
retry: {retries: 1, factor: 1, minTimeout: 1, maxTimeout: 1},
3738
});
3839

3940
await github.repos.get({repo: 'repo', owner: 'owner'});
@@ -72,7 +73,6 @@ test.serial('Use a https proxy', async t => {
7273
githubUrl: `https://localhost:${serverPort}`,
7374
githubApiPathPrefix: '',
7475
proxy: {host: 'localhost', port: proxyPort, rejectUnauthorized: false, headers: {foo: 'bar'}},
75-
retry: {retries: 1, factor: 1, minTimeout: 1, maxTimeout: 1},
7676
});
7777

7878
await github.repos.get({repo: 'repo', owner: 'owner'});
@@ -107,10 +107,10 @@ test('Use the global throttler for all endpoints', async t => {
107107
const issues = stub().callsFake(async () => Date.now());
108108
const octokit = {repos: {createRelease}, issues: {createComment}, search: {issues}, authenticate: stub()};
109109
const rate = 150;
110-
const github = proxyquire('../lib/get-client', {'@octokit/rest': stub().returns(octokit)})({
111-
limit: {search: 1, core: 1},
112-
globalLimit: rate,
113-
});
110+
const github = proxyquire('../lib/get-client', {
111+
'@octokit/rest': stub().returns(octokit),
112+
'./definitions/rate-limit': {RATE_LIMITS: {search: 1, core: 1}, GLOBAL_RATE_LIMIT: rate},
113+
})();
114114

115115
const a = await github.repos.createRelease();
116116
const b = await github.issues.createComment();
@@ -138,10 +138,10 @@ test('Use the same throttler for endpoints in the same rate limit group', async
138138
const octokit = {repos: {createRelease}, issues: {createComment}, search: {issues}, authenticate: stub()};
139139
const searchRate = 300;
140140
const coreRate = 150;
141-
const github = proxyquire('../lib/get-client', {'@octokit/rest': stub().returns(octokit)})({
142-
limit: {search: searchRate, core: coreRate},
143-
globalLimit: 1,
144-
});
141+
const github = proxyquire('../lib/get-client', {
142+
'@octokit/rest': stub().returns(octokit),
143+
'./definitions/rate-limit': {RATE_LIMITS: {search: searchRate, core: coreRate}, GLOBAL_RATE_LIMIT: 1},
144+
})();
145145

146146
const a = await github.repos.createRelease();
147147
const b = await github.issues.createComment();
@@ -173,11 +173,14 @@ test('Use the same throttler when retrying', async t => {
173173

174174
const octokit = {repos: {createRelease}, authenticate: stub()};
175175
const coreRate = 200;
176-
const github = proxyquire('../lib/get-client', {'@octokit/rest': stub().returns(octokit)})({
177-
limit: {core: coreRate},
178-
retry: {retries: 3, factor: 1, minTimeout: 1},
179-
globalLimit: 1,
180-
});
176+
const github = proxyquire('../lib/get-client', {
177+
'@octokit/rest': stub().returns(octokit),
178+
'./definitions/rate-limit': {
179+
RETRY_CONF: {retries: 3, factor: 1, minTimeout: 1},
180+
RATE_LIMITS: {core: coreRate},
181+
GLOBAL_RATE_LIMIT: 1,
182+
},
183+
})();
181184

182185
await t.throws(github.repos.createRelease());
183186
t.is(createRelease.callCount, 4);

test/helpers/rate-limit.js

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
const RETRY_CONF = {retries: 3, factor: 1, minTimeout: 1, maxTimeout: 1};
2+
3+
const RATE_LIMITS = {search: 1, core: {read: 1, write: 1}};
4+
5+
const GLOBAL_RATE_LIMIT = 1;
6+
7+
export default {RETRY_CONF, RATE_LIMITS, GLOBAL_RATE_LIMIT};

test/integration.test.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,17 @@ import test from 'ava';
33
import {stat} from 'fs-extra';
44
import nock from 'nock';
55
import {stub} from 'sinon';
6+
import proxyquire from 'proxyquire';
67
import clearModule from 'clear-module';
78
import SemanticReleaseError from '@semantic-release/error';
89
import {authenticate, upload} from './helpers/mock-github';
10+
import rateLimit from './helpers/rate-limit';
911

1012
/* eslint camelcase: ["error", {properties: "never"}] */
1113

1214
// Save the current process.env
1315
const envBackup = Object.assign({}, process.env);
16+
const client = proxyquire('../lib/get-client', {'./definitions/rate-limit': rateLimit});
1417

1518
test.beforeEach(t => {
1619
// Delete env variables in case they are on the machine running the tests
@@ -22,7 +25,12 @@ test.beforeEach(t => {
2225
delete process.env.GITHUB_PREFIX;
2326
// Clear npm cache to refresh the module state
2427
clearModule('..');
25-
t.context.m = require('..');
28+
t.context.m = proxyquire('..', {
29+
'./lib/verify': proxyquire('../lib/verify', {'./get-client': client}),
30+
'./lib/publish': proxyquire('../lib/publish', {'./get-client': client}),
31+
'./lib/success': proxyquire('../lib/success', {'./get-client': client}),
32+
'./lib/fail': proxyquire('../lib/fail', {'./get-client': client}),
33+
});
2634
// Stub the logger
2735
t.context.log = stub();
2836
t.context.error = stub();

test/publish.test.js

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,13 @@ import nock from 'nock';
55
import {stub} from 'sinon';
66
import proxyquire from 'proxyquire';
77
import tempy from 'tempy';
8-
import getClient from '../lib/get-client';
98
import {authenticate, upload} from './helpers/mock-github';
9+
import rateLimit from './helpers/rate-limit';
1010

1111
/* eslint camelcase: ["error", {properties: "never"}] */
1212

1313
const publish = proxyquire('../lib/publish', {
14-
'./get-client': conf =>
15-
getClient({
16-
...conf,
17-
...{retry: {retries: 3, factor: 1, minTimeout: 1, maxTimeout: 1}, limit: {search: 1, core: 1}, globalLimit: 1},
18-
}),
14+
'./get-client': proxyquire('../lib/get-client', {'./definitions/rate-limit': rateLimit}),
1915
});
2016

2117
// Save the current process.env

test/success.test.js

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,13 @@ import nock from 'nock';
55
import {stub} from 'sinon';
66
import proxyquire from 'proxyquire';
77
import ISSUE_ID from '../lib/definitions/sr-issue-id';
8-
import getClient from '../lib/get-client';
98
import {authenticate} from './helpers/mock-github';
9+
import rateLimit from './helpers/rate-limit';
1010

1111
/* eslint camelcase: ["error", {properties: "never"}] */
1212

1313
const success = proxyquire('../lib/success', {
14-
'./get-client': conf =>
15-
getClient({
16-
...conf,
17-
...{retry: {retries: 3, factor: 1, minTimeout: 1, maxTimeout: 1}, limit: {search: 1, core: 1}, globalLimit: 1},
18-
}),
14+
'./get-client': proxyquire('../lib/get-client', {'./definitions/rate-limit': rateLimit}),
1915
});
2016

2117
// Save the current process.env

0 commit comments

Comments
 (0)