diff --git a/README.md b/README.md index b0b87c7a..5e20174a 100644 --- a/README.md +++ b/README.md @@ -96,32 +96,43 @@ Pass `{ throttle: { enabled: false } }` to disable this plugin. ### Clustering -Enabling Clustering support ensures that your application will not go over rate limits **across Octokit instances and across Nodejs processes**. +Clustering support allows your application to coordinate rate limiting **across Octokit instances and across Node.js processes** using Redis. + +> **Note:** Redis clustering support is available but requires implementing a custom connection adapter. The `connection` parameter accepts any object that implements `disconnect(): Promise` and `on(event: string, handler: Function): void` methods. First install either `redis` or `ioredis`: ``` -# NodeRedis (https://github.com/NodeRedis/node_redis) +# NodeRedis (https://github.com/redis/node-redis) npm install --save redis -# or ioredis (https://github.com/luin/ioredis) +# or ioredis (https://github.com/redis/ioredis) npm install --save ioredis ``` -Then in your application: +Then create a connection adapter and pass it to the throttle options: ```js -import Bottleneck from "bottleneck"; import Redis from "redis"; const client = Redis.createClient({ /* options */ }); -const connection = new Bottleneck.RedisConnection({ client }); -connection.on("error", err => console.error(err)); + +// Create a connection adapter that implements the required interface +const connection = { + async disconnect() { + await client.disconnect(); + }, + on(event, handler) { + client.on(event, handler); + }, +}; + +connection.on("error", (err) => console.error(err)); const octokit = new MyOctokit({ - auth: 'secret123' + auth: "secret123", throttle: { onSecondaryRateLimit: (retryAfter, options, octokit) => { /* ... */ @@ -130,16 +141,13 @@ const octokit = new MyOctokit({ /* ... */ }, - // The Bottleneck connection object + // The connection object for Redis coordination connection, // A "throttling ID". All octokit instances with the same ID // using the same Redis server will share the throttling. id: "my-super-app", - - // Otherwise the plugin uses a lighter version of Bottleneck without Redis support - Bottleneck - } + }, }); // To close the connection and allow your application to exit cleanly: @@ -153,7 +161,16 @@ import Redis from "ioredis"; const client = new Redis({ /* options */ }); -const connection = new Bottleneck.IORedisConnection({ client }); + +const connection = { + async disconnect() { + await client.disconnect(); + }, + on(event, handler) { + client.on(event, handler); + }, +}; + connection.on("error", (err) => console.error(err)); ``` @@ -201,10 +218,10 @@ connection.on("error", (err) => console.error(err)); options.connection - Bottleneck.RedisConnection + Connection - A Bottleneck connection instance. See Clustering above. + A connection object for Redis clustering. Must implement disconnect(): Promise<void> and on(event: string, handler: Function): void methods. See Clustering above. @@ -218,17 +235,6 @@ connection.on("error", (err) => console.error(err)); A "throttling ID". All octokit instances with the same ID using the same Redis server will share the throttling. See Clustering above. Defaults to no-id. - - - options.Bottleneck - - - Bottleneck - - - Bottleneck constructor. See Clustering above. Defaults to `bottleneck/light`. - - diff --git a/package-lock.json b/package-lock.json index 13b7668f..867cc0a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@octokit/types": "^16.0.0", - "bottleneck": "^2.15.3" + "p-queue": "^9.0.0" }, "devDependencies": { "@octokit/auth-app": "^8.1.2", @@ -1262,7 +1262,6 @@ "integrity": "sha512-uWN8YqxXxqFMX2RqGOrumsKeti4LlmIMIyV0lgut4jx7KQBcBiW6vkDtIBvHnHIquwNfJhk8v2OtmO8zXWHfPA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -1491,12 +1490,6 @@ "dev": true, "license": "Apache-2.0" }, - "node_modules/bottleneck": { - "version": "2.19.5", - "resolved": "https://registry.npmjs.org/bottleneck/-/bottleneck-2.19.5.tgz", - "integrity": "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw==", - "license": "MIT" - }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -1703,6 +1696,12 @@ "@types/estree": "^1.0.0" } }, + "node_modules/eventemitter3": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", + "license": "MIT" + }, "node_modules/expect-type": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.2.2.tgz", @@ -1826,7 +1825,6 @@ "integrity": "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" } @@ -2138,6 +2136,34 @@ "node": "^18.17.0 || >=20.5.0" } }, + "node_modules/p-queue": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.0.0.tgz", + "integrity": "sha512-KO1RyxstL9g1mK76530TExamZC/S2Glm080Nx8PE5sTd7nlduDQsAfEl4uXX+qZjLiwvDauvzXavufy3+rJ9zQ==", + "license": "MIT", + "dependencies": { + "eventemitter3": "^5.0.1", + "p-timeout": "^7.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-timeout": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", + "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -2946,7 +2972,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", diff --git a/package.json b/package.json index 847fabae..ea9761e6 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "license": "MIT", "dependencies": { "@octokit/types": "^16.0.0", - "bottleneck": "^2.15.3" + "p-queue": "^9.0.0" }, "peerDependencies": { "@octokit/core": "^7.0.0" diff --git a/src/index.ts b/src/index.ts index 895151ab..5382e53d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,4 @@ -// @ts-expect-error No types for "bottleneck/light" -import BottleneckLight from "bottleneck/light.js"; -import type TBottleneck from "bottleneck"; +import { EventEmitter } from "node:events"; import type { Octokit, OctokitOptions } from "@octokit/core"; import type { CreateGroupsCommon, @@ -9,6 +7,8 @@ import type { ThrottlingOptions, } from "./types.js"; import { VERSION } from "./version.js"; +import { ThrottleGroup } from "./throttle-group.js"; +import { ThrottleLimiter } from "./throttle-limiter.js"; import { wrapRequest } from "./wrap-request.js"; import triggersNotificationPaths from "./generated/triggers-notification-paths.js"; @@ -21,33 +21,31 @@ const triggersNotification = regex.test.bind(regex); const groups: Groups = {}; -const createGroups = function ( - Bottleneck: typeof TBottleneck, - common: CreateGroupsCommon, -) { - groups.global = new Bottleneck.Group({ +const createGroups = function (common: CreateGroupsCommon) { + groups.global = new ThrottleGroup({ id: "octokit-global", maxConcurrent: 10, + minTime: 0, // Explicitly set to match Bottleneck's behavior ...common, }); - groups.auth = new Bottleneck.Group({ + groups.auth = new ThrottleGroup({ id: "octokit-auth", maxConcurrent: 1, ...common, }); - groups.search = new Bottleneck.Group({ + groups.search = new ThrottleGroup({ id: "octokit-search", maxConcurrent: 1, minTime: 2000, ...common, }); - groups.write = new Bottleneck.Group({ + groups.write = new ThrottleGroup({ id: "octokit-write", maxConcurrent: 1, minTime: 1000, ...common, }); - groups.notifications = new Bottleneck.Group({ + groups.notifications = new ThrottleGroup({ id: "octokit-notifications", maxConcurrent: 1, minTime: 3000, @@ -58,7 +56,6 @@ const createGroups = function ( export function throttling(octokit: Octokit, octokitOptions: OctokitOptions) { const { enabled = true, - Bottleneck = BottleneckLight as typeof TBottleneck, id = "no-id", timeout = 1000 * 60 * 2, // Redis TTL: 2 minutes connection, @@ -72,7 +69,7 @@ export function throttling(octokit: Octokit, octokitOptions: OctokitOptions) { } if (groups.global == null) { - createGroups(Bottleneck, common); + createGroups(common); } const state: State = Object.assign( @@ -81,7 +78,7 @@ export function throttling(octokit: Octokit, octokitOptions: OctokitOptions) { triggersNotification, fallbackSecondaryRateRetryAfter: 60, retryAfterBaseValue: 1000, - retryLimiter: new Bottleneck(), + retryLimiter: new ThrottleLimiter(), id, ...(groups as Required), }, @@ -105,17 +102,32 @@ export function throttling(octokit: Octokit, octokitOptions: OctokitOptions) { `); } - const events = {}; - const emitter = new Bottleneck.Events(events); - // @ts-expect-error - events.on("secondary-limit", state.onSecondaryRateLimit); - // @ts-expect-error - events.on("rate-limit", state.onRateLimit); - // @ts-expect-error - events.on("error", (e) => + const emitter = new EventEmitter(); + emitter.on("secondary-limit", state.onSecondaryRateLimit); + emitter.on("rate-limit", state.onRateLimit); + emitter.on("error", (e) => octokit.log.warn("Error in throttling-plugin limit handler", e), ); + // Helper to emit event and get handler return value + const emitAndGetResult = async ( + event: string, + ...args: any[] + ): Promise => { + try { + const listeners = emitter.listeners(event); + if (listeners.length > 0) { + const result = await (listeners[0] as any)(...args); + return result; + } + return undefined; + } catch (error) { + // Emit error event if handler throws + emitter.emit("error", error); + return undefined; + } + }; + state.retryLimiter.on("failed", async function (error, info) { const [state, request, options] = info.args as [ State, @@ -146,7 +158,7 @@ export function throttling(octokit: Octokit, octokitOptions: OctokitOptions) { const retryAfter = Number(error.response.headers["retry-after"]) || state.fallbackSecondaryRateRetryAfter; - const wantRetry = await emitter.trigger( + const wantRetry = await emitAndGetResult( "secondary-limit", retryAfter, options, @@ -175,7 +187,7 @@ export function throttling(octokit: Octokit, octokitOptions: OctokitOptions) { Math.ceil((rateLimitReset - Date.now()) / 1000) + 1, 0, ); - const wantRetry = await emitter.trigger( + const wantRetry = await emitAndGetResult( "rate-limit", retryAfter, options, diff --git a/src/throttle-group.ts b/src/throttle-group.ts new file mode 100644 index 00000000..b2ee8f81 --- /dev/null +++ b/src/throttle-group.ts @@ -0,0 +1,148 @@ +import PQueue from "p-queue"; +import type { Connection } from "./types.js"; + +interface ThrottleGroupOptions { + id: string; + maxConcurrent?: number; + minTime?: number; + timeout?: number; + connection?: Connection; +} + +interface JobOptions { + priority?: number; + weight?: number; + expiration?: number; +} + +/** + * Simple lock to serialize async operations (mimics Bottleneck's Sync class) + */ +class AsyncLock { + private queue: Array<() => Promise> = []; + private running = false; + + async schedule(fn: () => Promise): Promise { + return new Promise((resolve, reject) => { + this.queue.push(async () => { + try { + const result = await fn(); + resolve(result); + } catch (error) { + reject(error); + } + }); + this.tryRun(); + }); + } + + private async tryRun(): Promise { + if (this.running || this.queue.length === 0) { + return; + } + + this.running = true; + const task = this.queue.shift()!; + await task(); + this.running = false; + this.tryRun(); + } +} + +/** + * ThrottleGroup manages request queuing and rate limiting for a specific group + * Replaces Bottleneck.Group functionality with p-queue + * + * Note: In Bottleneck, maxConcurrent was shared across all keys in a group. + * We use a single shared queue for the entire group. + * + * Key Bottleneck behavior: Bottleneck uses internal Sync locks that serialize + * submission and draining operations, even with maxConcurrent > 1. This causes + * jobs to be added to the queue one at a time, which with async operations can + * result in serialized execution in practice. We replicate this with AsyncLock. + */ +export class ThrottleGroup { + private sharedQueue: PQueue; + private submitLock: AsyncLock = new AsyncLock(); + + constructor(options: ThrottleGroupOptions) { + // Create a single shared queue for the entire group + // Use actual maxConcurrent value for p-queue's concurrency + const queueOptions: { + concurrency?: number; + timeout?: number; + intervalCap?: number; + interval?: number; + } = {}; + + // Set concurrency to match maxConcurrent (or unlimited if not specified) + if (options.maxConcurrent !== undefined) { + queueOptions.concurrency = options.maxConcurrent; + } + + if (options.timeout !== undefined) { + queueOptions.timeout = options.timeout; + } + + // If minTime is specified, use p-queue's interval limiting + // minTime ensures minimum delay between task starts + if (options.minTime !== undefined && options.minTime > 0) { + queueOptions.intervalCap = 1; + queueOptions.interval = options.minTime; + } + + this.sharedQueue = new PQueue(queueOptions); + } + + /** + * Get a key-specific instance that uses the shared queue + */ + key(_id: string): ThrottleGroupKeyInstance { + return new ThrottleGroupKeyInstance(this.sharedQueue, this.submitLock); + } +} + +/** + * Represents a keyed instance of a throttle group + * Mimics Bottleneck's key() API + */ +class ThrottleGroupKeyInstance { + constructor( + private queue: PQueue, + private submitLock: AsyncLock, + ) {} + + /** + * Schedule a function to run with throttling + * Mimics Bottleneck's schedule() API + */ + async schedule( + jobOptions: JobOptions | ((...args: any[]) => Promise | T), + fnOrArg1?: ((...args: any[]) => Promise | T) | any, + ...args: any[] + ): Promise { + // Handle overloaded signature - schedule can be called with or without jobOptions + let fn: (...args: any[]) => Promise | T; + let actualArgs: any[]; + let options: JobOptions = {}; + + if (typeof jobOptions === "function") { + // Called as: schedule(fn, ...args) + fn = jobOptions; + actualArgs = [fnOrArg1, ...args]; + } else { + // Called as: schedule(options, fn, ...args) + options = jobOptions; + fn = fnOrArg1 as (...args: any[]) => Promise | T; + actualArgs = args; + } + + // Add to queue with priority if specified + const priority = options.priority !== undefined ? -options.priority : 0; + + // Use submitLock to serialize queue submissions (mimics Bottleneck's _submitLock) + return this.submitLock.schedule(() => + this.queue.add(async () => fn(...actualArgs), { priority }), + ); + } +} diff --git a/src/throttle-limiter.ts b/src/throttle-limiter.ts new file mode 100644 index 00000000..a47cbcbf --- /dev/null +++ b/src/throttle-limiter.ts @@ -0,0 +1,58 @@ +import PQueue from "p-queue"; + +/** + * Simple throttle limiter for retry logic + * Replaces basic Bottleneck instance functionality + */ +export class ThrottleLimiter { + private queue: PQueue; + private failedHandlers: Set< + (error: any, info: { args: any[] }) => Promise + > = new Set(); + + constructor() { + this.queue = new PQueue({ concurrency: Infinity }); + } + + /** + * Schedule a function to run with retry capability + */ + async schedule( + fn: (...args: any[]) => Promise | T, + ...args: any[] + ): Promise { + const executeWithRetry = async (): Promise => { + try { + return await fn(...args); + } catch (error) { + // Call failed handlers and check if we should retry + for (const handler of this.failedHandlers) { + const retryAfter = await handler(error, { args }); + if (typeof retryAfter === "number" && retryAfter >= 0) { + // Wait and retry (retryAfter can be 0 for immediate retry) + if (retryAfter > 0) { + await new Promise((resolve) => setTimeout(resolve, retryAfter)); + } + return executeWithRetry(); + } + } + // No retry, rethrow the error + throw error; + } + }; + + return this.queue.add(executeWithRetry); + } + + /** + * Register a handler for failed requests + */ + on( + event: "failed", + handler: (error: any, info: { args: any[] }) => Promise, + ): void { + if (event === "failed") { + this.failedHandlers.add(handler); + } + } +} diff --git a/src/types.ts b/src/types.ts index 6989c24c..54157a5e 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,6 +1,14 @@ import type { Octokit } from "@octokit/core"; import type { EndpointDefaults } from "@octokit/types"; -import type Bottleneck from "bottleneck"; +import type { ThrottleGroup } from "./throttle-group.js"; +import type { ThrottleLimiter } from "./throttle-limiter.js"; + +// Generic connection interface for Redis clustering support +// Users can pass any connection object that implements these methods +export interface Connection { + disconnect(): Promise; + on(event: string, handler: (...args: any[]) => void): void; +} type LimitHandler = ( retryAfter: number, @@ -15,19 +23,18 @@ export type SecondaryLimitHandler = { export type ThrottlingOptionsBase = { enabled?: boolean; - Bottleneck?: typeof Bottleneck; id?: string; timeout?: number; - connection?: Bottleneck.RedisConnection | Bottleneck.IORedisConnection; + connection?: Connection; /** * @deprecated use `fallbackSecondaryRateRetryAfter` */ minimalSecondaryRateRetryAfter?: number; fallbackSecondaryRateRetryAfter?: number; retryAfterBaseValue?: number; - write?: Bottleneck.Group; - search?: Bottleneck.Group; - notifications?: Bottleneck.Group; + write?: ThrottleGroup; + search?: ThrottleGroup; + notifications?: ThrottleGroup; onRateLimit: LimitHandler; }; @@ -38,11 +45,11 @@ export type ThrottlingOptions = }); export type Groups = { - global?: Bottleneck.Group; - auth?: Bottleneck.Group; - write?: Bottleneck.Group; - search?: Bottleneck.Group; - notifications?: Bottleneck.Group; + global?: ThrottleGroup; + auth?: ThrottleGroup; + write?: ThrottleGroup; + search?: ThrottleGroup; + notifications?: ThrottleGroup; }; export type State = { @@ -50,12 +57,12 @@ export type State = { triggersNotification: (pathname: string) => boolean; fallbackSecondaryRateRetryAfter: number; retryAfterBaseValue: number; - retryLimiter: Bottleneck; + retryLimiter: ThrottleLimiter; id: string; } & Required & ThrottlingOptions; export type CreateGroupsCommon = { - connection?: Bottleneck.RedisConnection | Bottleneck.IORedisConnection; + connection?: Connection; timeout: number; }; diff --git a/src/wrap-request.ts b/src/wrap-request.ts index a7af4d1c..6024484d 100644 --- a/src/wrap-request.ts +++ b/src/wrap-request.ts @@ -54,10 +54,7 @@ async function doRequest( const req = (isAuth ? state.auth : state.global) .key(state.id) - .schedule< - OctokitResponse, - Required - >(jobOptions, request, options); + .schedule>(jobOptions, request, options); if (isGraphQL) { const res = await req; diff --git a/test/plugin.test.ts b/test/plugin.test.ts index 7914c90b..f8c603f8 100644 --- a/test/plugin.test.ts +++ b/test/plugin.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import Bottleneck from "bottleneck"; +import { ThrottleGroup } from "../src/throttle-group.ts"; import { createAppAuth } from "@octokit/auth-app"; import { TestOctokit } from "./octokit.ts"; import { throttling } from "../src/index.ts"; @@ -122,10 +122,9 @@ describe("GitHub API best practices", function () { it("Should maintain 1000ms between mutating or GraphQL requests", async function () { const octokit = new TestOctokit({ throttle: { - write: new Bottleneck.Group({ minTime: 50 }), + write: new ThrottleGroup({ id: "test", minTime: 50 }), onSecondaryRateLimit: () => 1, onRateLimit: () => 1, - connection: new Bottleneck({ minTime: 50 }), }, }); @@ -172,8 +171,8 @@ describe("GitHub API best practices", function () { it("Should maintain 3000ms between requests that trigger notifications", async function () { const octokit = new TestOctokit({ throttle: { - write: new Bottleneck.Group({ minTime: 50 }), - notifications: new Bottleneck.Group({ minTime: 100 }), + write: new ThrottleGroup({ id: "test", minTime: 50 }), + notifications: new ThrottleGroup({ id: "test", minTime: 100 }), onSecondaryRateLimit: () => 1, onRateLimit: () => 1, }, @@ -249,7 +248,7 @@ describe("GitHub API best practices", function () { it("Should maintain 2000ms between search requests", async function () { const octokit = new TestOctokit({ throttle: { - search: new Bottleneck.Group({ minTime: 50 }), + search: new ThrottleGroup({ id: "test", minTime: 50 }), onSecondaryRateLimit: () => 1, onRateLimit: () => 1, }, @@ -302,8 +301,8 @@ describe("GitHub API best practices", function () { it("Should optimize throughput rather than maintain ordering", async function () { const octokit = new TestOctokit({ throttle: { - write: new Bottleneck.Group({ minTime: 50 }), - notifications: new Bottleneck.Group({ minTime: 150 }), + write: new ThrottleGroup({ id: "test", minTime: 50 }), + notifications: new ThrottleGroup({ id: "test", minTime: 150 }), onSecondaryRateLimit: () => 1, onRateLimit: () => 1, }, diff --git a/test/retry.test.ts b/test/retry.test.ts index d8f0a221..f7fdeda8 100644 --- a/test/retry.test.ts +++ b/test/retry.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import Bottleneck from "bottleneck"; +import { ThrottleGroup } from "../src/throttle-group.ts"; import { TestOctokit } from "./octokit.ts"; import { Octokit } from "@octokit/core"; import { throttling } from "../src/index.ts"; @@ -236,7 +236,7 @@ describe( let eventCount = 0; const octokit = new TestOctokit({ throttle: { - write: new Bottleneck.Group({ minTime: 50 }), + write: new ThrottleGroup({ id: "test", minTime: 50 }), onRateLimit: (retryAfter, options) => { expect(options).toMatchObject({ method: "POST", @@ -288,7 +288,7 @@ describe( let eventCount = 0; const octokit = new TestOctokit({ throttle: { - write: new Bottleneck.Group({ minTime: 50 }), + write: new ThrottleGroup({ id: "test", minTime: 50 }), onRateLimit: (retryAfter, options) => { expect(options).toMatchObject({ method: "POST", @@ -341,7 +341,7 @@ describe( let eventCount = 0; const octokit = new TestOctokit({ throttle: { - write: new Bottleneck.Group({ minTime: 50 }), + write: new ThrottleGroup({ id: "test", minTime: 50 }), onRateLimit: () => { eventCount++; return true; @@ -379,7 +379,7 @@ describe( let eventCount = 0; const octokit = new TestOctokit({ throttle: { - write: new Bottleneck.Group({ minTime: 50 }), + write: new ThrottleGroup({ id: "test", minTime: 50 }), onRateLimit: () => { eventCount++; return true; @@ -420,7 +420,7 @@ describe( let eventCount = 0; const octokit = new TestOctokit({ throttle: { - write: new Bottleneck.Group({ minTime: 50 }), + write: new ThrottleGroup({ id: "test", minTime: 50 }), onSecondaryRateLimit: () => { eventCount++; return true;