diff --git a/plugins/scaffolder-backend-module-github/src/util.test.ts b/plugins/scaffolder-backend-module-github/src/util.test.ts new file mode 100644 index 0000000000..9cb3035f21 --- /dev/null +++ b/plugins/scaffolder-backend-module-github/src/util.test.ts @@ -0,0 +1,154 @@ +/* + * Copyright 2025 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 & { + plugin: jest.Mock; +}; + +describe('isRetryEnabled', () => { + it('returns true when both params are undefined', () => { + expect(isRetryEnabled()).toBe(true); + }); + + it('returns true when retries is positive and retryDelay is undefined', () => { + expect(isRetryEnabled(3)).toBe(true); + }); + + it('returns true when retries is undefined and retryDelay is positive', () => { + expect(isRetryEnabled(undefined, 1000)).toBe(true); + }); + + it('returns true when both retries and retryDelay are positive', () => { + expect(isRetryEnabled(5, 2000)).toBe(true); + }); + + it('returns false when retries is 0', () => { + expect(isRetryEnabled(0)).toBe(false); + }); + + it('returns false when retryDelay is 0', () => { + expect(isRetryEnabled(undefined, 0)).toBe(false); + }); + + it('returns false when retries is 0 even if retryDelay is positive', () => { + expect(isRetryEnabled(0, 1000)).toBe(false); + }); + + it('returns false when retryDelay is 0 even if retries is positive', () => { + expect(isRetryEnabled(3, 0)).toBe(false); + }); +}); + +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 client = getOctokitClient(octokitOptions, logger, 0); + + expect(MockOctokit.plugin).not.toHaveBeenCalled(); + expect(MockOctokit).toHaveBeenCalledWith({ + ...octokitOptions, + log: logger, + }); + expect(client).toBe(mockOctokitInstance); + }); + + it('returns a plain Octokit client when retryDelay is 0', () => { + const client = getOctokitClient(octokitOptions, logger, 3, 0); + + 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', () => { + 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 client = getOctokitClient(octokitOptions, logger, 5, 2000); + + 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 }, + }; + + getOctokitClient(optionsWithRequest, logger, 3, 1000); + + expect(MockOctokit).toHaveBeenCalledWith({ + ...optionsWithRequest, + request: { + timeout: 60_000, + retries: 3, + retryAfter: 1000, + }, + log: logger, + }); + }); +}); diff --git a/plugins/scaffolder-backend-module-github/src/util.ts b/plugins/scaffolder-backend-module-github/src/util.ts index 260c93993f..0d50b7c797 100644 --- a/plugins/scaffolder-backend-module-github/src/util.ts +++ b/plugins/scaffolder-backend-module-github/src/util.ts @@ -22,9 +22,90 @@ 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 = 1000; + +/** + * Helper function to determine if retries are enabled based on the provided + * options. + * + * Retries are enabled by default, but can be disabled by setting either `retries` + * or `retryDelay` to 0. + * + * @param retries - The number of retry attempts for failed requests. Default is 3. + * Setting to 0 will disable retries. + * @param retryDelay - The delay in milliseconds between retry attempts. + * Default is 1000ms. Setting to 0 will disable retries. + * + * @returns A boolean indicating whether retries are enabled or not. + */ +export function isRetryEnabled(retries?: number, retryDelay?: number): boolean { + if (retries === 0) { + return false; + } + + if (retryDelay === 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 `retryDelay` to 0 in the options. + * + * @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 retries - The number of retry attempts for failed requests. + * Default is 3. Setting to 0 will disable retries. + * @param retryDelay - The delay in milliseconds between retry attempts. + * Default is 1000ms. Setting to 0 will disable retries. + * @returns An authenticated Octokit client instance based on the provided + * options. + */ +export function getOctokitClient( + octokitOptions: OctokitOptions, + logger: LoggerService, + retries: number = DEFAULT_RETRY_ATTEMPTS, + retryDelay: number = DEFAULT_RETRY_DELAY_MS, +): Octokit { + // Default behavior is to enable retries, but allow callers to disable by + // explicitly setting retries or retryDelay to 0 + if (!isRetryEnabled(retries, retryDelay)) { + return new Octokit({ + ...octokitOptions, + log: logger, + }); + } + + // Update the octokit options to include retry configuration with logging + const MyOctokit = Octokit.plugin(retry); + return new MyOctokit({ + ...octokitOptions, + request: { + ...octokitOptions.request, + retries, + retryAfter: retryDelay, + }, + log: logger, + }); +} + /** * Helper for generating octokit configuration options. * If no token is provided, it will attempt to get a token from the credentials provider.