1 | import BottleneckLight from 'bottleneck/light';
|
2 |
|
3 | const VERSION = "3.0.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 | const PATHS = [
|
53 | "/orgs/:org/invitations",
|
54 | "/repos/:owner/:repo/collaborators/:username",
|
55 | "/repos/:owner/:repo/commits/:commit_sha/comments",
|
56 | "/repos/:owner/:repo/issues",
|
57 | "/repos/:owner/:repo/issues/:issue_number/comments",
|
58 | "/repos/:owner/:repo/pulls",
|
59 | "/repos/:owner/:repo/pulls/:pull_number/comments",
|
60 | "/repos/:owner/:repo/pulls/:pull_number/comments/:comment_id/replies",
|
61 | "/repos/:owner/:repo/pulls/:pull_number/merge",
|
62 | "/repos/:owner/:repo/pulls/:pull_number/requested_reviewers",
|
63 | "/repos/:owner/:repo/pulls/:pull_number/reviews",
|
64 | "/repos/:owner/:repo/releases",
|
65 | "/teams/:team_id/discussions",
|
66 | "/teams/:team_id/discussions/:discussion_number/comments"
|
67 | ];
|
68 |
|
69 |
|
70 | function routeMatcher(paths) {
|
71 |
|
72 | |
73 |
|
74 |
|
75 |
|
76 |
|
77 | const regexes = paths.map(path => path
|
78 | .split("/")
|
79 |
|
80 | .map(c => (c.startsWith(":") ? "(?:.+?)" : c))
|
81 | .join("/"));
|
82 |
|
83 | |
84 |
|
85 |
|
86 |
|
87 |
|
88 | const regex = `^(?:${regexes.map(r => `(?:${r})`).join("|")})[^/]*$`;
|
89 |
|
90 | |
91 |
|
92 |
|
93 |
|
94 |
|
95 |
|
96 | return new RegExp(regex, "i");
|
97 | }
|
98 |
|
99 |
|
100 |
|
101 | const regex = routeMatcher(PATHS);
|
102 | const triggersNotification = regex.test.bind(regex);
|
103 | const groups = {};
|
104 |
|
105 | const createGroups = function (Bottleneck, common) {
|
106 |
|
107 | groups.global = new Bottleneck.Group({
|
108 | id: "octokit-global",
|
109 | maxConcurrent: 10,
|
110 | ...common
|
111 | });
|
112 |
|
113 | groups.search = new Bottleneck.Group({
|
114 | id: "octokit-search",
|
115 | maxConcurrent: 1,
|
116 | minTime: 2000,
|
117 | ...common
|
118 | });
|
119 |
|
120 | groups.write = new Bottleneck.Group({
|
121 | id: "octokit-write",
|
122 | maxConcurrent: 1,
|
123 | minTime: 1000,
|
124 | ...common
|
125 | });
|
126 |
|
127 | groups.notifications = new Bottleneck.Group({
|
128 | id: "octokit-notifications",
|
129 | maxConcurrent: 1,
|
130 | minTime: 3000,
|
131 | ...common
|
132 | });
|
133 | };
|
134 | function throttling(octokit, octokitOptions = {}) {
|
135 | const { enabled = true, Bottleneck = BottleneckLight, id = "no-id", timeout = 1000 * 60 * 2,
|
136 | connection
|
137 |
|
138 | } = octokitOptions.throttle || {};
|
139 | if (!enabled) {
|
140 | return;
|
141 | }
|
142 | const common = { connection, timeout };
|
143 |
|
144 | if (groups.global == null) {
|
145 | createGroups(Bottleneck, common);
|
146 | }
|
147 | const state = Object.assign({
|
148 | clustering: connection != null,
|
149 | triggersNotification,
|
150 | minimumAbuseRetryAfter: 5,
|
151 | retryAfterBaseValue: 1000,
|
152 | retryLimiter: new Bottleneck(),
|
153 | id,
|
154 | ...groups
|
155 | },
|
156 |
|
157 | octokitOptions.throttle);
|
158 | if (typeof state.onAbuseLimit !== "function" ||
|
159 | typeof state.onRateLimit !== "function") {
|
160 | throw new Error(`octokit/plugin-throttling error:
|
161 | You must pass the onAbuseLimit and onRateLimit error handlers.
|
162 | See https://github.com/octokit/rest.js#throttling
|
163 |
|
164 | const octokit = new Octokit({
|
165 | throttle: {
|
166 | onAbuseLimit: (retryAfter, options) => {/* ... */},
|
167 | onRateLimit: (retryAfter, options) => {/* ... */}
|
168 | }
|
169 | })
|
170 | `);
|
171 | }
|
172 | const events = {};
|
173 | const emitter = new Bottleneck.Events(events);
|
174 |
|
175 | events.on("abuse-limit", state.onAbuseLimit);
|
176 |
|
177 | events.on("rate-limit", state.onRateLimit);
|
178 |
|
179 | events.on("error", e => console.warn("Error in throttling-plugin limit handler", e));
|
180 |
|
181 | state.retryLimiter.on("failed", async function (error, info) {
|
182 | const options = info.args[info.args.length - 1];
|
183 | const isGraphQL = options.url.startsWith("/graphql");
|
184 | if (!(isGraphQL || error.status === 403)) {
|
185 | return;
|
186 | }
|
187 | const retryCount = ~~options.request.retryCount;
|
188 | options.request.retryCount = retryCount;
|
189 | const { wantRetry, retryAfter } = await (async function () {
|
190 | if (/\babuse\b/i.test(error.message)) {
|
191 |
|
192 |
|
193 |
|
194 |
|
195 | const retryAfter = Math.max(~~error.headers["retry-after"], state.minimumAbuseRetryAfter);
|
196 | const wantRetry = await emitter.trigger("abuse-limit", retryAfter, options);
|
197 | return { wantRetry, retryAfter };
|
198 | }
|
199 | if (error.headers != null &&
|
200 | error.headers["x-ratelimit-remaining"] === "0") {
|
201 |
|
202 |
|
203 | const rateLimitReset = new Date(~~error.headers["x-ratelimit-reset"] * 1000).getTime();
|
204 | const retryAfter = Math.max(Math.ceil((rateLimitReset - Date.now()) / 1000), 0);
|
205 | const wantRetry = await emitter.trigger("rate-limit", retryAfter, options);
|
206 | return { wantRetry, retryAfter };
|
207 | }
|
208 | return {};
|
209 | })();
|
210 | if (wantRetry) {
|
211 | options.request.retryCount++;
|
212 |
|
213 | return retryAfter * state.retryAfterBaseValue;
|
214 | }
|
215 | });
|
216 | octokit.hook.wrap("request", wrapRequest.bind(null, state));
|
217 | }
|
218 | throttling.VERSION = VERSION;
|
219 | throttling.triggersNotification = triggersNotification;
|
220 |
|
221 | export { throttling };
|
222 |
|