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