diff --git a/.changeset/sweet-ravens-glow.md b/.changeset/sweet-ravens-glow.md new file mode 100644 index 0000000000..045f7edbf2 --- /dev/null +++ b/.changeset/sweet-ravens-glow.md @@ -0,0 +1,6 @@ +--- +'@backstage/integration': minor +'@backstage/backend-common': patch +--- + +Fix rate limit detection by looking for HTTP status code 429 and updating the header `x-ratelimit-remaining` to look for in case of a 403 code is returned diff --git a/packages/backend-common/src/reading/GithubUrlReader.ts b/packages/backend-common/src/reading/GithubUrlReader.ts index 67f6a975c1..462821a20f 100644 --- a/packages/backend-common/src/reading/GithubUrlReader.ts +++ b/packages/backend-common/src/reading/GithubUrlReader.ts @@ -333,10 +333,7 @@ export class GithubUrlReader implements UrlReader { // GitHub returns a 403 response with a couple of headers indicating rate // limit status. See more in the GitHub docs: // https://docs.github.com/en/rest/overview/resources-in-the-rest-api#rate-limiting - if ( - response.status === 403 && - response.headers.get('X-RateLimit-Remaining') === '0' - ) { + if (this.integration.parseRateLimitInfo(response).isRateLimited) { message += ' (rate limit exceeded)'; } diff --git a/packages/integration/api-report.md b/packages/integration/api-report.md index 634e03dbfc..07b4f7d012 100644 --- a/packages/integration/api-report.md +++ b/packages/integration/api-report.md @@ -4,6 +4,7 @@ ```ts import { Config } from '@backstage/config'; +import { ConsumedResponse } from '@backstage/errors'; import { RestEndpointMethodTypes } from '@octokit/rest'; // @public @@ -567,6 +568,8 @@ export class GithubIntegration implements ScmIntegration { // (undocumented) static factory: ScmIntegrationsFactory; // (undocumented) + parseRateLimitInfo(response: ConsumedResponse): RateLimitInfo; + // (undocumented) resolveEditUrl(url: string): string; // (undocumented) resolveUrl(options: { @@ -694,6 +697,12 @@ export type PersonalAccessTokenCredential = AzureCredentialBase & { personalAccessToken: string; }; +// @public +export interface RateLimitInfo { + // (undocumented) + isRateLimited: boolean; +} + // @public export function readAwsS3IntegrationConfig( config: Config, diff --git a/packages/integration/package.json b/packages/integration/package.json index 7e57293903..29a4273698 100644 --- a/packages/integration/package.json +++ b/packages/integration/package.json @@ -35,6 +35,7 @@ "dependencies": { "@azure/identity": "^4.0.0", "@backstage/config": "workspace:^", + "@backstage/errors": "workspace:^", "@octokit/auth-app": "^4.0.0", "@octokit/rest": "^19.0.3", "cross-fetch": "^4.0.0", diff --git a/packages/integration/src/github/GithubIntegration.test.ts b/packages/integration/src/github/GithubIntegration.test.ts index d8e6020a64..70f1c4a16c 100644 --- a/packages/integration/src/github/GithubIntegration.test.ts +++ b/packages/integration/src/github/GithubIntegration.test.ts @@ -78,6 +78,33 @@ describe('GithubIntegration', () => { ), ).toBe('https://github.com/backstage/backstage/edit/master/README.md'); }); + + describe('isRateLimited', () => { + const integration = new GithubIntegration({ host: 'h.com' }); + + it.each` + status | ratelimitRemaining | expected + ${404} | ${100} | ${false} + ${429} | ${undefined} | ${true} + ${429} | ${100} | ${true} + ${403} | ${100} | ${false} + ${403} | ${0} | ${true} + `( + '(statusCode: $status, header: $ratelimitRemaining) === $expected', + ({ status, ratelimitRemaining, expected }) => { + const headers = new Headers({ + 'x-ratelimit-remaining': ratelimitRemaining, + }); + const result = integration.parseRateLimitInfo({ + status, + headers, + } as Response); + expect(result).toMatchObject({ + isRateLimited: expected, + }); + }, + ); + }); }); describe('replaceGithubUrlType', () => { diff --git a/packages/integration/src/github/GithubIntegration.ts b/packages/integration/src/github/GithubIntegration.ts index 50fc129ccf..880a2bed21 100644 --- a/packages/integration/src/github/GithubIntegration.ts +++ b/packages/integration/src/github/GithubIntegration.ts @@ -15,11 +15,16 @@ */ import { basicIntegrations, defaultScmResolveUrl } from '../helpers'; -import { ScmIntegration, ScmIntegrationsFactory } from '../types'; +import { + RateLimitInfo, + ScmIntegration, + ScmIntegrationsFactory, +} from '../types'; import { GithubIntegrationConfig, readGithubIntegrationConfigs, } from './config'; +import { ConsumedResponse } from '@backstage/errors'; /** * A GitHub based integration. @@ -65,6 +70,15 @@ export class GithubIntegration implements ScmIntegration { resolveEditUrl(url: string): string { return replaceGithubUrlType(url, 'edit'); } + + parseRateLimitInfo(response: ConsumedResponse): RateLimitInfo { + return { + isRateLimited: + response.status === 429 || + (response.status === 403 && + response.headers.get('x-ratelimit-remaining') === '0'), + }; + } } /** diff --git a/packages/integration/src/index.ts b/packages/integration/src/index.ts index 5738da3da0..700d7dc67c 100644 --- a/packages/integration/src/index.ts +++ b/packages/integration/src/index.ts @@ -37,5 +37,6 @@ export type { ScmIntegration, ScmIntegrationsFactory, ScmIntegrationsGroup, + RateLimitInfo, } from './types'; export type { ScmIntegrationRegistry } from './registry'; diff --git a/packages/integration/src/types.ts b/packages/integration/src/types.ts index cb4b78eb7c..0f0fca942e 100644 --- a/packages/integration/src/types.ts +++ b/packages/integration/src/types.ts @@ -108,3 +108,12 @@ export interface ScmIntegrationsGroup { export type ScmIntegrationsFactory = (options: { config: Config; }) => ScmIntegrationsGroup; + +/** + * Encapsulates information about the RateLimit state + * + * @public + */ +export interface RateLimitInfo { + isRateLimited: boolean; +} diff --git a/yarn.lock b/yarn.lock index 1c83813e10..23bb660897 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4147,6 +4147,7 @@ __metadata: "@backstage/cli": "workspace:^" "@backstage/config": "workspace:^" "@backstage/config-loader": "workspace:^" + "@backstage/errors": "workspace:^" "@octokit/auth-app": ^4.0.0 "@octokit/rest": ^19.0.3 "@types/luxon": ^3.0.0