Merge pull request #34027 from adobejmong/feat-add-util-to-get-octokit-client

feat: add util to get octokit client which support retries via octokit/plugin-retry
This commit is contained in:
Andre Wanlin
2026-05-13 10:33:03 -05:00
committed by GitHub
7 changed files with 284 additions and 1 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-scaffolder-backend-module-github': patch
---
Improved Octokit client creation to support retries via @octokit/plugin-retry
@@ -51,6 +51,7 @@
"@backstage/plugin-scaffolder-node": "workspace:^",
"@backstage/types": "workspace:^",
"@octokit/core": "^5.0.0",
"@octokit/plugin-retry": "^6.0.0",
"@octokit/webhooks": "^10.9.2",
"libsodium-wrappers": "^0.8.0",
"octokit": "^3.0.0",
@@ -8,6 +8,7 @@ import { CatalogService } from '@backstage/plugin-catalog-node';
import { Config } from '@backstage/config';
import { createPullRequest } from 'octokit-plugin-create-pull-request';
import { GithubCredentialsProvider } from '@backstage/integration';
import { LoggerService } from '@backstage/backend-plugin-api';
import { Octokit } from 'octokit';
import { OctokitOptions } from '@octokit/core/dist-types/types';
import { ScmIntegrationRegistry } from '@backstage/integration';
@@ -509,6 +510,13 @@ export const createPublishGithubPullRequestAction: (
'v2'
>;
// @public
export function getOctokitClient(
octokitOptions: OctokitOptions,
logger: LoggerService,
retryOptions?: RetryOptions,
): Octokit;
// @public
export function getOctokitOptions(options: {
integrations: ScmIntegrationRegistry;
@@ -530,4 +538,10 @@ export function getOctokitOptions(options: {
// @public
const githubModule: BackendFeature;
export default githubModule;
// @public
export type RetryOptions = {
retries?: number;
retryAfter?: number;
};
```
@@ -22,4 +22,4 @@
export * from './actions';
export { githubModule as default } from './module';
export { getOctokitOptions } from './util';
export { getOctokitClient, getOctokitOptions, type RetryOptions } from './util';
@@ -0,0 +1,161 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Octokit } from 'octokit';
import { retry } from '@octokit/plugin-retry';
import { mockServices } from '@backstage/backend-test-utils';
import { isRetryEnabled, getOctokitClient } from './util';
jest.mock('octokit', () => ({
Octokit: Object.assign(jest.fn(), {
plugin: jest.fn(),
}),
}));
jest.mock('@octokit/plugin-retry', () => ({
retry: jest.fn(),
}));
const mockOctokitInstance = { request: jest.fn() };
const MockOctokit = Octokit as unknown as jest.MockedClass<typeof Octokit> & {
plugin: jest.Mock;
};
describe('isRetryEnabled', () => {
it.each([
{
description: 'returns false when retries is == 0',
options: { retries: 0 },
expected: false,
},
{
description: 'returns false when retries is <= 0',
options: { retries: -10 },
expected: false,
},
{
description: 'returns false when retryAfter is == 0',
options: { retries: 1, retryAfter: 0 },
expected: false,
},
{
description: 'returns false when retryAfter is <= 0',
options: { retries: 1, retryAfter: -10 },
expected: false,
},
{
description: 'returns true when retryOptions is undefined',
options: undefined,
expected: true,
},
{
description: 'returns true when retries is > 0 and retryAfter is > 0',
options: { retries: 5, retryAfter: 5 },
expected: true,
},
])('$description', ({ options, expected }) => {
expect(isRetryEnabled(options)).toBe(expected);
});
});
describe('getOctokitClient', () => {
const logger = mockServices.logger.mock();
const octokitOptions = {
auth: 'test-token',
baseUrl: 'https://api.github.com',
};
beforeEach(() => {
jest.clearAllMocks();
MockOctokit.mockReturnValue(mockOctokitInstance as any);
MockOctokit.plugin.mockReturnValue(MockOctokit);
});
it('returns a plain Octokit client when retries is 0', () => {
const retryOptions = { retries: 0 };
const client = getOctokitClient(octokitOptions, logger, retryOptions);
expect(MockOctokit.plugin).not.toHaveBeenCalled();
expect(MockOctokit).toHaveBeenCalledWith({
...octokitOptions,
log: logger,
});
expect(client).toBe(mockOctokitInstance);
});
it('returns a plain Octokit client when retryAfter is 0', () => {
const retryOptions = { retries: 3, retryAfter: 0 };
const client = getOctokitClient(octokitOptions, logger, retryOptions);
expect(MockOctokit.plugin).not.toHaveBeenCalled();
expect(MockOctokit).toHaveBeenCalledWith({
...octokitOptions,
log: logger,
});
expect(client).toBe(mockOctokitInstance);
});
it('returns a retry-enabled Octokit client with default retries and delay when retryOptions is undefined', () => {
const client = getOctokitClient(octokitOptions, logger);
expect(MockOctokit.plugin).toHaveBeenCalledWith(retry);
expect(MockOctokit).toHaveBeenCalledWith({
...octokitOptions,
request: {
retries: 3,
retryAfter: 1000,
},
log: logger,
});
expect(client).toBe(mockOctokitInstance);
});
it('returns a retry-enabled Octokit client with custom retries and delay', () => {
const retryOptions = { retries: 5, retryAfter: 2000 };
const client = getOctokitClient(octokitOptions, logger, retryOptions);
expect(MockOctokit.plugin).toHaveBeenCalledWith(retry);
expect(MockOctokit).toHaveBeenCalledWith({
...octokitOptions,
request: {
retries: 5,
retryAfter: 2000,
},
log: logger,
});
expect(client).toBe(mockOctokitInstance);
});
it('merges existing request options when building retry client', () => {
const optionsWithRequest = {
...octokitOptions,
request: { timeout: 60_000 },
};
const retryOptions = { retries: 3, retryAfter: 1000 };
getOctokitClient(optionsWithRequest, logger, retryOptions);
expect(MockOctokit).toHaveBeenCalledWith({
...optionsWithRequest,
request: {
timeout: 60_000,
retries: 3,
retryAfter: 1000,
},
log: logger,
});
});
});
@@ -22,9 +22,110 @@ import {
} from '@backstage/integration';
import { parseRepoUrl } from '@backstage/plugin-scaffolder-node';
import { OctokitOptions } from '@octokit/core/dist-types/types';
import { Octokit } from 'octokit';
import { retry } from '@octokit/plugin-retry';
import { LoggerService } from '@backstage/backend-plugin-api';
const DEFAULT_TIMEOUT_MS = 60_000;
// By default, octokit/plugin-retry will retry 3 times with a 1 second delay
// between retries.
const DEFAULT_RETRY_ATTEMPTS = 3;
const DEFAULT_RETRY_DELAY_MS = 1_000;
/**
* Options used to override plugin-retry defaults
* @public
*/
export type RetryOptions = {
// The number of retry attempts for failed requests
retries?: number;
// The delay in milliseconds between retry attempts
retryAfter?: number;
};
/**
* Helper function to determine if retries are enabled based on the provided
* options.
*
* @param retryOptions - Optional retry configuration options, including the number
* of retries and the delay between retries.
*
* @returns False if retries/retryAfter are explicitly set to 0 or less.
* Returns true otherwise.
*/
export function isRetryEnabled(retryOptions?: RetryOptions): boolean {
if (retryOptions === undefined) {
return true;
}
if (retryOptions.retries !== undefined && retryOptions.retries <= 0) {
return false;
}
if (retryOptions.retryAfter !== undefined && retryOptions.retryAfter <= 0) {
return false;
}
return true;
}
/**
* Helper for generating an authenticated Octokit client with (or without)
* retry capabilities.
*
* If retries are enabled (default), the client will retry failed requests up
* to the specified number of retries and delay.
* To disable retries, set either `retries` or `retryAfter` to 0 in the options.
* @public
*
* @param octokitOptions - The options for configuring the Octokit client.
* Generally provided by the `getOctokitOptions` helper.
* @param logger - LoggerService instance for logging retry attempts and
* failures.
* @param retryOptions - Optional retry configuration options, including the
* number of retries and the delay between retries.
*
* @returns An authenticated Octokit client instance based on the provided
* options.
*/
export function getOctokitClient(
octokitOptions: OctokitOptions,
logger: LoggerService,
retryOptions?: RetryOptions,
): Octokit {
// Default behavior is to enable retries, but allow callers to disable by
// explicitly setting retries or retryAfter to 0
if (!isRetryEnabled(retryOptions)) {
return new Octokit({
...octokitOptions,
log: logger,
});
}
// Update the octokit options to include retry configuration with logging
const OctokitClient = Octokit.plugin(retry);
// From the octokit/plugin-retry documentation, specifying these values will
// always retry regardless of the response code
const retries = retryOptions?.retries
? retryOptions?.retries
: DEFAULT_RETRY_ATTEMPTS;
const retryAfter = retryOptions?.retryAfter
? retryOptions?.retryAfter
: DEFAULT_RETRY_DELAY_MS;
return new OctokitClient({
...octokitOptions,
request: {
...octokitOptions.request,
retries,
retryAfter,
},
log: logger,
});
}
/**
* Helper for generating octokit configuration options.
* If no token is provided, it will attempt to get a token from the credentials provider.
+1
View File
@@ -6733,6 +6733,7 @@ __metadata:
"@backstage/plugin-scaffolder-node-test-utils": "workspace:^"
"@backstage/types": "workspace:^"
"@octokit/core": "npm:^5.0.0"
"@octokit/plugin-retry": "npm:^6.0.0"
"@octokit/webhooks": "npm:^10.9.2"
"@types/libsodium-wrappers": "npm:^0.8.0"
fs-extra: "npm:^11.2.0"