Skip to content

Commit

Permalink
Add an internal rate limit (#4610)
Browse files Browse the repository at this point in the history
* Add an internal rate limit
Fixes #4546

* Fix some error/info messages
  • Loading branch information
alexr00 authored Mar 10, 2023
1 parent 5b49aad commit 93129e3
Show file tree
Hide file tree
Showing 9 changed files with 73 additions and 54 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2362,6 +2362,7 @@
"dayjs": "1.10.4",
"events": "3.2.0",
"fast-deep-equal": "^3.1.3",
"limiter": "^2.1.0",
"lru-cache": "6.0.0",
"marked": "^4.0.10",
"react": "^16.12.0",
Expand Down
8 changes: 8 additions & 0 deletions src/@types/vscode.proposed.contribEditorLineNumberMenu.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

// empty placeholder declaration for the `editor/lineNumber/context` menu contribution point

// https://github.com/microsoft/vscode/issues/175945 @joyceerhl
1 change: 1 addition & 0 deletions src/@types/vscode.proposed.contribShareMenu.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
*--------------------------------------------------------------------------------------------*/

// empty placeholder declaration for the `file/share`-submenu contribution point
// https://github.com/microsoft/vscode/issues/176316
4 changes: 4 additions & 0 deletions src/@types/vscode.proposed.quickDiffProvider.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,8 @@ declare module 'vscode' {
export namespace window {
export function registerQuickDiffProvider(selector: DocumentSelector, quickDiffProvider: QuickDiffProvider, label: string, rootUri?: Uri): Disposable;
}

interface QuickDiffProvider {
label?: string;
}
}
2 changes: 1 addition & 1 deletion src/github/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,7 @@ export class CredentialStore implements vscode.Disposable {
},
});

const rateLogger = new RateLogger(this._context, this._telemetry);
const rateLogger = new RateLogger(this._telemetry);
const github: GitHub = {
octokit: new LoggingOctokit(octokit, rateLogger),
graphql: new LoggingApolloClient(graphql, rateLogger),
Expand Down
94 changes: 44 additions & 50 deletions src/github/loggingOctokit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,12 @@

import { Octokit } from '@octokit/rest';
import { ApolloClient, ApolloQueryResult, FetchResult, MutationOptions, NormalizedCacheObject, OperationVariables, QueryOptions } from 'apollo-boost';
import { RateLimiter } from 'limiter';
import * as vscode from 'vscode';
import Logger from '../common/logger';
import { ITelemetry } from '../common/telemetry';
import { RateLimit } from './graphql';

const RATE_COUNTER_LAST_WINDOW = 'rateCounterLastWindow';
const RATE_COUNTER_COUNT = 'rateCounterCount';

interface RestResponse {
headers: {
'x-ratelimit-limit': string;
Expand All @@ -21,44 +19,30 @@ interface RestResponse {
}

export class RateLogger {
private lastWindow: number;
private count: number = 0;
private limiter: RateLimiter;
private static ID = 'RateLimit';
private hasLoggedLowRateLimit: boolean = false;

constructor(private readonly context: vscode.ExtensionContext, private readonly telemetry: ITelemetry) {
// We assume the common case for this logging: only one user.
// We also make up our own window. This will not line up exactly with GitHub's rate limit reset time,
// but it will give us a nice idea of how many API calls we're making. We use an hour, just like GitHub.
this.lastWindow = this.context.globalState.get(RATE_COUNTER_LAST_WINDOW, 0);
// It looks like there might be separate rate limits for the REST and GraphQL api.
// We'll just count total API calls as a lower bound.
this.count = this.context.globalState.get(RATE_COUNTER_COUNT, 0);
this.tryUpdateWindow();
constructor(private readonly telemetry: ITelemetry) {
this.limiter = new RateLimiter({ tokensPerInterval: 100, interval: 'second' });
}

private tryUpdateWindow() {
const now = new Date().getTime();
if ((now - this.lastWindow) > (60 * 60 * 1000) /* 1 hour */) {
this.lastWindow = now;
this.context.globalState.update(RATE_COUNTER_LAST_WINDOW, this.lastWindow);
this.count = 0;
public logAndLimit(): boolean {
if (!this.limiter.tryRemoveTokens(1)) {
Logger.error('API call count has exceeded 100 calls in 1 second.', RateLogger.ID);
// We have hit 100 requests in 1 second. This likely indicates a bug in the extension.
/* __GDPR__
"pr.highApiCallRate" : {}
*/
this.telemetry.sendTelemetryErrorEvent('pr.highApiCallRate');
vscode.window.showErrorMessage(vscode.l10n.t('The GitHub Pull Requests extension is making too many requests to GitHub. This indicates a bug in the extension. Please file an issue on GitHub and include the output from "GitHub Pull Request".'));
return false;
}
Logger.debug(`Extension rate limit remaining: ${this.limiter.getTokensRemaining()}`, RateLogger.ID);
return true;
}

public log(info: string | undefined) {
this.tryUpdateWindow();
this.count++;
this.context.globalState.update(RATE_COUNTER_COUNT, this.count);
const countMessage = `API call count: ${this.count}${info ? ` (${info})` : ''}`;
if (this.count > 4000) {
Logger.appendLine(countMessage, RateLogger.ID);
} else {
Logger.debug(countMessage, RateLogger.ID);
}
}

public async logRateLimit(result: Promise<{ data: { rateLimit: RateLimit | undefined } | undefined } | undefined>, isRest: boolean = false) {
public async logRateLimit(info: string | undefined, result: Promise<{ data: { rateLimit: RateLimit | undefined } | undefined } | undefined>, isRest: boolean = false) {
let rateLimitInfo;
try {
rateLimitInfo = (await result)?.data?.rateLimit;
Expand All @@ -69,21 +53,22 @@ export class RateLogger {
if ((rateLimitInfo?.limit ?? 5000) < 5000) {
Logger.appendLine(`Unexpectedly low rate limit: ${rateLimitInfo?.limit}`, RateLogger.ID);
}
const remaining = `${isRest ? 'REST' : 'GraphQL'} Rate limit remaining: ${rateLimitInfo?.remaining}`;
const remaining = `${isRest ? 'REST' : 'GraphQL'} Rate limit remaining: ${rateLimitInfo?.remaining}, ${info}`;
if ((rateLimitInfo?.remaining ?? 1000) < 1000) {
Logger.appendLine(remaining, RateLogger.ID);
} else {
if (!this.hasLoggedLowRateLimit) {
/* __GDPR__
"pr.lowRateLimitRemaining" : {}
*/
this.telemetry.sendTelemetryErrorEvent('pr.lowRateLimitRemaining');
this.hasLoggedLowRateLimit = true;
}
Logger.warn(remaining, RateLogger.ID);
} else {
Logger.debug(remaining, RateLogger.ID);
}
}

public async logRestRateLimit(restResponse: Promise<RestResponse>) {
public async logRestRateLimit(info: string | undefined, restResponse: Promise<RestResponse>) {
let result;
try {
result = await restResponse;
Expand All @@ -97,35 +82,44 @@ export class RateLogger {
remaining: Number(result.headers['x-ratelimit-remaining']),
resetAt: ''
};
this.logRateLimit(Promise.resolve({ data: { rateLimit } }), true);
this.logRateLimit(info, Promise.resolve({ data: { rateLimit } }), true);
}
}

export class LoggingApolloClient {
constructor(private readonly _graphql: ApolloClient<NormalizedCacheObject>, private _rateLogger: RateLogger) { };

query<T = any, TVariables = OperationVariables>(options: QueryOptions<TVariables>): Promise<ApolloQueryResult<T>> {
this._rateLogger.log((options.query.definitions[0] as { name: { value: string } | undefined }).name?.value);
const result = this._graphql.query(options);
this._rateLogger.logRateLimit(result as any);
return result;
if (this._rateLogger.logAndLimit()) {
const result = this._graphql.query(options);
this._rateLogger.logRateLimit((options.query.definitions[0] as { name: { value: string } | undefined }).name?.value, result as any);
return result;
} else {
throw new Error('API call count has exceeded a rate limit.');
}
}

mutate<T = any, TVariables = OperationVariables>(options: MutationOptions<T, TVariables>): Promise<FetchResult<T>> {
this._rateLogger.log(options.context);
const result = this._graphql.mutate(options);
this._rateLogger.logRateLimit(result as any);
return result;
if (this._rateLogger.logAndLimit()) {
const result = this._graphql.mutate(options);
this._rateLogger.logRateLimit(options.context, result as any);
return result;
} else {
throw new Error('API call count has exceeded a rate limit.');
}
}
}

export class LoggingOctokit {
constructor(public readonly api: Octokit, private _rateLogger: RateLogger) { };

async call<T, U>(api: (T) => Promise<U>, args: T): Promise<U> {
this._rateLogger.log((api as unknown as { endpoint: { DEFAULTS: { url: string } | undefined } | undefined }).endpoint?.DEFAULTS?.url);
const result = api(args);
this._rateLogger.logRestRateLimit(result as Promise<unknown> as Promise<RestResponse>);
return result;
if (this._rateLogger.logAndLimit()) {
const result = api(args);
this._rateLogger.logRestRateLimit((api as unknown as { endpoint: { DEFAULTS: { url: string } | undefined } | undefined }).endpoint?.DEFAULTS?.url, result as Promise<unknown> as Promise<RestResponse>);
return result;
} else {
throw new Error('API call count has exceeded a rate limit.');
}
}
}
3 changes: 1 addition & 2 deletions src/test/mocks/mockGitHubRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@ import {
import { MockTelemetry } from './mockTelemetry';
import { Uri } from 'vscode';
import { LoggingOctokit, RateLogger } from '../../github/loggingOctokit';
import { MockExtensionContext } from './mockExtensionContext';
const queries = require('../../github/queries.gql');

export class MockGitHubRepository extends GitHubRepository {
Expand All @@ -32,7 +31,7 @@ export class MockGitHubRepository extends GitHubRepository {
this.queryProvider = new QueryProvider(sinon);

this._hub = {
octokit: new LoggingOctokit(this.queryProvider.octokit, new RateLogger(new MockExtensionContext(), new MockTelemetry())),
octokit: new LoggingOctokit(this.queryProvider.octokit, new RateLogger(new MockTelemetry())),
graphql: null,
};

Expand Down
2 changes: 1 addition & 1 deletion src/test/view/prsTree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('GitHub Pull Requests view', function () {
baseUrl: 'https://github.com',
userAgent: 'GitHub VSCode Pull Requests',
previews: ['shadow-cat-preview'],
}), new RateLogger(context, telemetry)),
}), new RateLogger(telemetry)),
graphql: null,
};

Expand Down
12 changes: 12 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3625,6 +3625,11 @@ just-extend@^4.0.2:
resolved "https://registry.yarnpkg.com/just-extend/-/just-extend-4.1.1.tgz#158f1fdb01f128c411dc8b286a7b4837b3545282"
integrity sha512-aWgeGFW67BP3e5181Ep1Fv2v8z//iBJfrvyTnq8wG86vEESwmonn1zPBJ0VfmT9CJq2FIT0VsETtrNFm2a+SHA==

just-performance@4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/just-performance/-/just-performance-4.3.0.tgz#cc2bc8c9227f09e97b6b1df4cd0de2df7ae16db1"
integrity sha512-L7RjvtJsL0QO8xFs5wEoDDzzJwoiowRw6Rn/GnvldlchS2JQr9wFYPiwZcDfrbbujEKqKN0tvENdbjXdYhDp5Q==

keygrip@~1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/keygrip/-/keygrip-1.1.0.tgz#871b1681d5e159c62a445b0c74b615e0917e7226"
Expand Down Expand Up @@ -3741,6 +3746,13 @@ levn@~0.3.0:
prelude-ls "~1.1.2"
type-check "~0.3.2"

limiter@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/limiter/-/limiter-2.1.0.tgz#d38d7c5b63729bb84fb0c4d8594b7e955a5182a2"
integrity sha512-361TYz6iay6n+9KvUUImqdLuFigK+K79qrUtBsXhJTLdH4rIt/r1y8r1iozwh8KbZNpujbFTSh74mJ7bwbAMOw==
dependencies:
just-performance "4.3.0"

lines-and-columns@^1.1.6:
version "1.1.6"
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00"
Expand Down

0 comments on commit 93129e3

Please sign in to comment.