UNPKG

8.44 kBJavaScriptView Raw
1import BottleneckLight from 'bottleneck/light';
2
3const VERSION = "3.1.0";
4
5const noop = () => Promise.resolve();
6// @ts-ignore
7function wrapRequest(state, request, options) {
8 return state.retryLimiter.schedule(doRequest, state, request, options);
9}
10// @ts-ignore
11async function doRequest(state, request, options) {
12 const isWrite = options.method !== "GET" && options.method !== "HEAD";
13 const isSearch = options.method === "GET" && options.url.startsWith("/search/");
14 const isGraphQL = options.url.startsWith("/graphql");
15 const retryCount = ~~options.request.retryCount;
16 const jobOptions = retryCount > 0 ? { priority: 0, weight: 0 } : {};
17 if (state.clustering) {
18 // Remove a job from Redis if it has not completed or failed within 60s
19 // Examples: Node process terminated, client disconnected, etc.
20 // @ts-ignore
21 jobOptions.expiration = 1000 * 60;
22 }
23 // Guarantee at least 1000ms between writes
24 // GraphQL can also trigger writes
25 if (isWrite || isGraphQL) {
26 await state.write.key(state.id).schedule(jobOptions, noop);
27 }
28 // Guarantee at least 3000ms between requests that trigger notifications
29 if (isWrite && state.triggersNotification(options.url)) {
30 await state.notifications.key(state.id).schedule(jobOptions, noop);
31 }
32 // Guarantee at least 2000ms between search requests
33 if (isSearch) {
34 await state.search.key(state.id).schedule(jobOptions, noop);
35 }
36 const req = state.global.key(state.id).schedule(jobOptions, request, options);
37 if (isGraphQL) {
38 const res = await req;
39 if (res.data.errors != null &&
40 // @ts-ignore
41 res.data.errors.some(error => error.type === "RATE_LIMITED")) {
42 const error = Object.assign(new Error("GraphQL Rate Limit Exceeded"), {
43 headers: res.headers,
44 data: res.data
45 });
46 throw error;
47 }
48 }
49 return req;
50}
51
52var triggersNotificationPaths = [
53 "/orgs/:org/invitations",
54 "/orgs/:org/teams/:team_slug/discussions",
55 "/orgs/:org/teams/:team_slug/discussions/:discussion_number/comments",
56 "/repos/:owner/:repo/collaborators/:username",
57 "/repos/:owner/:repo/commits/:commit_sha/comments",
58 "/repos/:owner/:repo/issues",
59 "/repos/:owner/:repo/issues/:issue_number/comments",
60 "/repos/:owner/:repo/pulls",
61 "/repos/:owner/:repo/pulls/:pull_number/comments",
62 "/repos/:owner/:repo/pulls/:pull_number/comments/:comment_id/replies",
63 "/repos/:owner/:repo/pulls/:pull_number/merge",
64 "/repos/:owner/:repo/pulls/:pull_number/requested_reviewers",
65 "/repos/:owner/:repo/pulls/:pull_number/reviews",
66 "/repos/:owner/:repo/releases",
67 "/teams/:team_id/discussions",
68 "/teams/:team_id/discussions/:discussion_number/comments"
69];
70
71// @ts-ignore
72function routeMatcher(paths) {
73 // EXAMPLE. For the following paths:
74 /* [
75 "/orgs/:org/invitations",
76 "/repos/:owner/:repo/collaborators/:username"
77 ] */
78 // @ts-ignore
79 const regexes = paths.map(path => path
80 .split("/")
81 // @ts-ignore
82 .map(c => (c.startsWith(":") ? "(?:.+?)" : c))
83 .join("/"));
84 // 'regexes' would contain:
85 /* [
86 '/orgs/(?:.+?)/invitations',
87 '/repos/(?:.+?)/(?:.+?)/collaborators/(?:.+?)'
88 ] */
89 // @ts-ignore
90 const regex = `^(?:${regexes.map(r => `(?:${r})`).join("|")})[^/]*$`;
91 // 'regex' would contain:
92 /*
93 ^(?:(?:\/orgs\/(?:.+?)\/invitations)|(?:\/repos\/(?:.+?)\/(?:.+?)\/collaborators\/(?:.+?)))[^\/]*$
94
95 It may look scary, but paste it into https://www.debuggex.com/
96 and it will make a lot more sense!
97 */
98 return new RegExp(regex, "i");
99}
100
101// @ts-ignore
102// Workaround to allow tests to directly access the triggersNotification function.
103const regex = routeMatcher(triggersNotificationPaths);
104const triggersNotification = regex.test.bind(regex);
105const groups = {};
106// @ts-ignore
107const createGroups = function (Bottleneck, common) {
108 // @ts-ignore
109 groups.global = new Bottleneck.Group({
110 id: "octokit-global",
111 maxConcurrent: 10,
112 ...common
113 });
114 // @ts-ignore
115 groups.search = new Bottleneck.Group({
116 id: "octokit-search",
117 maxConcurrent: 1,
118 minTime: 2000,
119 ...common
120 });
121 // @ts-ignore
122 groups.write = new Bottleneck.Group({
123 id: "octokit-write",
124 maxConcurrent: 1,
125 minTime: 1000,
126 ...common
127 });
128 // @ts-ignore
129 groups.notifications = new Bottleneck.Group({
130 id: "octokit-notifications",
131 maxConcurrent: 1,
132 minTime: 3000,
133 ...common
134 });
135};
136function throttling(octokit, octokitOptions = {}) {
137 const { enabled = true, Bottleneck = BottleneckLight, id = "no-id", timeout = 1000 * 60 * 2, // Redis TTL: 2 minutes
138 connection
139 // @ts-ignore
140 } = octokitOptions.throttle || {};
141 if (!enabled) {
142 return;
143 }
144 const common = { connection, timeout };
145 // @ts-ignore
146 if (groups.global == null) {
147 createGroups(Bottleneck, common);
148 }
149 const state = Object.assign({
150 clustering: connection != null,
151 triggersNotification,
152 minimumAbuseRetryAfter: 5,
153 retryAfterBaseValue: 1000,
154 retryLimiter: new Bottleneck(),
155 id,
156 ...groups
157 },
158 // @ts-ignore
159 octokitOptions.throttle);
160 if (typeof state.onAbuseLimit !== "function" ||
161 typeof state.onRateLimit !== "function") {
162 throw new Error(`octokit/plugin-throttling error:
163 You must pass the onAbuseLimit and onRateLimit error handlers.
164 See https://github.com/octokit/rest.js#throttling
165
166 const octokit = new Octokit({
167 throttle: {
168 onAbuseLimit: (retryAfter, options) => {/* ... */},
169 onRateLimit: (retryAfter, options) => {/* ... */}
170 }
171 })
172 `);
173 }
174 const events = {};
175 const emitter = new Bottleneck.Events(events);
176 // @ts-ignore
177 events.on("abuse-limit", state.onAbuseLimit);
178 // @ts-ignore
179 events.on("rate-limit", state.onRateLimit);
180 // @ts-ignore
181 events.on("error", e => console.warn("Error in throttling-plugin limit handler", e));
182 // @ts-ignore
183 state.retryLimiter.on("failed", async function (error, info) {
184 const options = info.args[info.args.length - 1];
185 const isGraphQL = options.url.startsWith("/graphql");
186 if (!(isGraphQL || error.status === 403)) {
187 return;
188 }
189 const retryCount = ~~options.request.retryCount;
190 options.request.retryCount = retryCount;
191 const { wantRetry, retryAfter } = await (async function () {
192 if (/\babuse\b/i.test(error.message)) {
193 // The user has hit the abuse rate limit. (REST only)
194 // https://developer.github.com/v3/#abuse-rate-limits
195 // The Retry-After header can sometimes be blank when hitting an abuse limit,
196 // but is always present after 2-3s, so make sure to set `retryAfter` to at least 5s by default.
197 const retryAfter = Math.max(~~error.headers["retry-after"], state.minimumAbuseRetryAfter);
198 const wantRetry = await emitter.trigger("abuse-limit", retryAfter, options);
199 return { wantRetry, retryAfter };
200 }
201 if (error.headers != null &&
202 error.headers["x-ratelimit-remaining"] === "0") {
203 // The user has used all their allowed calls for the current time period (REST and GraphQL)
204 // https://developer.github.com/v3/#rate-limiting
205 const rateLimitReset = new Date(~~error.headers["x-ratelimit-reset"] * 1000).getTime();
206 const retryAfter = Math.max(Math.ceil((rateLimitReset - Date.now()) / 1000), 0);
207 const wantRetry = await emitter.trigger("rate-limit", retryAfter, options);
208 return { wantRetry, retryAfter };
209 }
210 return {};
211 })();
212 if (wantRetry) {
213 options.request.retryCount++;
214 // @ts-ignore
215 return retryAfter * state.retryAfterBaseValue;
216 }
217 });
218 octokit.hook.wrap("request", wrapRequest.bind(null, state));
219}
220throttling.VERSION = VERSION;
221throttling.triggersNotification = triggersNotification;
222
223export { throttling };
224//# sourceMappingURL=index.js.map