1 | import BottleneckLight from 'bottleneck/light';
|
2 |
|
3 | const VERSION = "3.1.0";
|
4 |
|
5 | const noop = () => Promise.resolve();
|
6 |
|
7 | function wrapRequest(state, request, options) {
|
8 | return state.retryLimiter.schedule(doRequest, state, request, options);
|
9 | }
|
10 |
|
11 | async 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 |
|
19 |
|
20 |
|
21 | jobOptions.expiration = 1000 * 60;
|
22 | }
|
23 |
|
24 |
|
25 | if (isWrite || isGraphQL) {
|
26 | await state.write.key(state.id).schedule(jobOptions, noop);
|
27 | }
|
28 |
|
29 | if (isWrite && state.triggersNotification(options.url)) {
|
30 | await state.notifications.key(state.id).schedule(jobOptions, noop);
|
31 | }
|
32 |
|
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 |
|
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 |
|
52 | var 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 |
|
72 | function routeMatcher(paths) {
|
73 |
|
74 | |
75 |
|
76 |
|
77 |
|
78 |
|
79 | const regexes = paths.map(path => path
|
80 | .split("/")
|
81 |
|
82 | .map(c => (c.startsWith(":") ? "(?:.+?)" : c))
|
83 | .join("/"));
|
84 |
|
85 | |
86 |
|
87 |
|
88 |
|
89 |
|
90 | const regex = `^(?:${regexes.map(r => `(?:${r})`).join("|")})[^/]*$`;
|
91 |
|
92 | |
93 |
|
94 |
|
95 |
|
96 |
|
97 |
|
98 | return new RegExp(regex, "i");
|
99 | }
|
100 |
|
101 |
|
102 |
|
103 | const regex = routeMatcher(triggersNotificationPaths);
|
104 | const triggersNotification = regex.test.bind(regex);
|
105 | const groups = {};
|
106 |
|
107 | const createGroups = function (Bottleneck, common) {
|
108 |
|
109 | groups.global = new Bottleneck.Group({
|
110 | id: "octokit-global",
|
111 | maxConcurrent: 10,
|
112 | ...common
|
113 | });
|
114 |
|
115 | groups.search = new Bottleneck.Group({
|
116 | id: "octokit-search",
|
117 | maxConcurrent: 1,
|
118 | minTime: 2000,
|
119 | ...common
|
120 | });
|
121 |
|
122 | groups.write = new Bottleneck.Group({
|
123 | id: "octokit-write",
|
124 | maxConcurrent: 1,
|
125 | minTime: 1000,
|
126 | ...common
|
127 | });
|
128 |
|
129 | groups.notifications = new Bottleneck.Group({
|
130 | id: "octokit-notifications",
|
131 | maxConcurrent: 1,
|
132 | minTime: 3000,
|
133 | ...common
|
134 | });
|
135 | };
|
136 | function throttling(octokit, octokitOptions = {}) {
|
137 | const { enabled = true, Bottleneck = BottleneckLight, id = "no-id", timeout = 1000 * 60 * 2,
|
138 | connection
|
139 |
|
140 | } = octokitOptions.throttle || {};
|
141 | if (!enabled) {
|
142 | return;
|
143 | }
|
144 | const common = { connection, timeout };
|
145 |
|
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 |
|
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 |
|
177 | events.on("abuse-limit", state.onAbuseLimit);
|
178 |
|
179 | events.on("rate-limit", state.onRateLimit);
|
180 |
|
181 | events.on("error", e => console.warn("Error in throttling-plugin limit handler", e));
|
182 |
|
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 |
|
194 |
|
195 |
|
196 |
|
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 |
|
204 |
|
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 |
|
215 | return retryAfter * state.retryAfterBaseValue;
|
216 | }
|
217 | });
|
218 | octokit.hook.wrap("request", wrapRequest.bind(null, state));
|
219 | }
|
220 | throttling.VERSION = VERSION;
|
221 | throttling.triggersNotification = triggersNotification;
|
222 |
|
223 | export { throttling };
|
224 |
|