UNPKG

4.19 kBJavaScriptView Raw
1module.exports = throttlingPlugin
2
3const BottleneckLight = require('bottleneck/light')
4const wrapRequest = require('./wrap-request')
5const triggersNotificationPaths = require('./triggers-notification-paths')
6const routeMatcher = require('./route-matcher')(triggersNotificationPaths)
7
8// Workaround to allow tests to directly access the triggersNotification function.
9const triggersNotification = throttlingPlugin.triggersNotification =
10 routeMatcher.test.bind(routeMatcher)
11
12const groups = {}
13
14const createGroups = function (Bottleneck, common) {
15 groups.global = new Bottleneck.Group({
16 id: 'octokit-global',
17 maxConcurrent: 10,
18 ...common
19 })
20 groups.search = new Bottleneck.Group({
21 id: 'octokit-search',
22 maxConcurrent: 1,
23 minTime: 2000,
24 ...common
25 })
26 groups.write = new Bottleneck.Group({
27 id: 'octokit-write',
28 maxConcurrent: 1,
29 minTime: 1000,
30 ...common
31 })
32 groups.notifications = new Bottleneck.Group({
33 id: 'octokit-notifications',
34 maxConcurrent: 1,
35 minTime: 3000,
36 ...common
37 })
38}
39
40function throttlingPlugin (octokit, octokitOptions = {}) {
41 const {
42 enabled = true,
43 Bottleneck = BottleneckLight,
44 id = 'no-id',
45 timeout = 1000 * 60 * 2, // Redis TTL: 2 minutes
46 connection
47 } = octokitOptions.throttle || {}
48 if (!enabled) {
49 return
50 }
51 const common = { connection, timeout }
52
53 if (groups.global == null) {
54 createGroups(Bottleneck, common)
55 }
56
57 const state = Object.assign({
58 clustering: connection != null,
59 triggersNotification,
60 minimumAbuseRetryAfter: 5,
61 retryAfterBaseValue: 1000,
62 retryLimiter: new Bottleneck(),
63 id,
64 ...groups
65 }, octokitOptions.throttle)
66
67 if (typeof state.onAbuseLimit !== 'function' || typeof state.onRateLimit !== 'function') {
68 throw new Error(`octokit/plugin-throttling error:
69 You must pass the onAbuseLimit and onRateLimit error handlers.
70 See https://github.com/octokit/rest.js#throttling
71
72 const octokit = new Octokit({
73 throttle: {
74 onAbuseLimit: (error, options) => {/* ... */},
75 onRateLimit: (error, options) => {/* ... */}
76 }
77 })
78 `)
79 }
80
81 const events = {}
82 const emitter = new Bottleneck.Events(events)
83 events.on('abuse-limit', state.onAbuseLimit)
84 events.on('rate-limit', state.onRateLimit)
85 events.on('error', e => console.warn('Error in throttling-plugin limit handler', e))
86
87 state.retryLimiter.on('failed', async function (error, info) {
88 const options = info.args[info.args.length - 1]
89 const isGraphQL = options.url.startsWith('/graphql')
90
91 if (!(isGraphQL || error.status === 403)) {
92 return
93 }
94
95 const retryCount = ~~options.request.retryCount
96 options.request.retryCount = retryCount
97
98 const { wantRetry, retryAfter } = await (async function () {
99 if (/\babuse\b/i.test(error.message)) {
100 // The user has hit the abuse rate limit. (REST only)
101 // https://developer.github.com/v3/#abuse-rate-limits
102
103 // The Retry-After header can sometimes be blank when hitting an abuse limit,
104 // but is always present after 2-3s, so make sure to set `retryAfter` to at least 5s by default.
105 const retryAfter = Math.max(~~error.headers['retry-after'], state.minimumAbuseRetryAfter)
106 const wantRetry = await emitter.trigger('abuse-limit', retryAfter, options)
107 return { wantRetry, retryAfter }
108 }
109 if (error.headers != null && error.headers['x-ratelimit-remaining'] === '0') {
110 // The user has used all their allowed calls for the current time period (REST and GraphQL)
111 // https://developer.github.com/v3/#rate-limiting
112
113 const rateLimitReset = new Date(~~error.headers['x-ratelimit-reset'] * 1000).getTime()
114 const retryAfter = Math.max(Math.ceil((rateLimitReset - Date.now()) / 1000), 0)
115 const wantRetry = await emitter.trigger('rate-limit', retryAfter, options)
116 return { wantRetry, retryAfter }
117 }
118 return {}
119 })()
120
121 if (wantRetry) {
122 options.request.retryCount++
123 return retryAfter * state.retryAfterBaseValue
124 }
125 })
126
127 octokit.hook.wrap('request', wrapRequest.bind(null, state))
128}