diff --git a/.changeset/dry-phones-type.md b/.changeset/dry-phones-type.md new file mode 100644 index 0000000000..ad2cf40113 --- /dev/null +++ b/.changeset/dry-phones-type.md @@ -0,0 +1,5 @@ +--- +'@backstage/release-manifests': patch +--- + +Added a fallback that fetches manifests from `https://raw.githubusercontent.com` if `https://versions.backstage.io` is unavailable. diff --git a/packages/release-manifests/src/manifest.test.ts b/packages/release-manifests/src/manifest.test.ts index 272555df70..6a53f993c8 100644 --- a/packages/release-manifests/src/manifest.test.ts +++ b/packages/release-manifests/src/manifest.test.ts @@ -17,7 +17,11 @@ import { setupServer } from 'msw/node'; import { rest } from 'msw'; import { setupRequestMockHandlers } from '@backstage/test-utils'; -import { getManifestByReleaseLine, getManifestByVersion } from './manifest'; +import { + getManifestByReleaseLine, + getManifestByVersion, + withFallback, +} from './manifest'; describe('Release Manifests', () => { const worker = setupServer(); @@ -86,3 +90,50 @@ describe('Release Manifests', () => { }); }); }); + +describe('withFallback', () => { + it('should use the first value to resolve', async () => { + const fn1 = jest.fn((_s: AbortSignal) => Promise.resolve(1)); + const fn2 = jest.fn((_s: AbortSignal) => Promise.resolve(2)); + await expect(withFallback(fn1, fn2, 100)).resolves.toBe(1); + expect(fn1.mock.lastCall?.[0].aborted).toBe(false); + expect(fn2).not.toHaveBeenCalled(); + }); + + it('should fall back on rejection', async () => { + const fn1 = jest.fn((_s: AbortSignal) => Promise.reject(new Error('1'))); + const fn2 = jest.fn((_s: AbortSignal) => Promise.resolve(2)); + await expect(withFallback(fn1, fn2, 0)).resolves.toBe(2); + expect(fn1.mock.lastCall?.[0].aborted).toBe(true); + expect(fn2.mock.lastCall?.[0].aborted).toBe(false); + }); + + it('should fall back on timeout', async () => { + const fn1 = jest.fn((_s: AbortSignal) => new Promise(() => {})); + const fn2 = jest.fn((_s: AbortSignal) => Promise.resolve(2)); + await expect(withFallback(fn1, fn2, 0)).resolves.toBe(2); + expect(fn1.mock.lastCall?.[0].aborted).toBe(true); + expect(fn2.mock.lastCall?.[0].aborted).toBe(false); + }); + + it('should always reject with the first error', async () => { + const fn1 = jest.fn((_s: AbortSignal) => Promise.reject(new Error('1'))); + const fn2 = jest.fn((_s: AbortSignal) => Promise.reject(new Error('2'))); + await expect(withFallback(fn1, fn2, 0)).rejects.toThrow('1'); + expect(fn1.mock.lastCall?.[0].aborted).toBe(false); + expect(fn2.mock.lastCall?.[0].aborted).toBe(false); + }); + + it('should always reject with the first error even if rejected after', async () => { + const fn1 = jest.fn( + (_s: AbortSignal) => + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('1')), 100); + }), + ); + const fn2 = jest.fn((_s: AbortSignal) => Promise.reject(new Error('2'))); + await expect(withFallback(fn1, fn2, 0)).rejects.toThrow('1'); + expect(fn1.mock.lastCall?.[0].aborted).toBe(false); + expect(fn2.mock.lastCall?.[0].aborted).toBe(false); + }); +}); diff --git a/packages/release-manifests/src/manifest.ts b/packages/release-manifests/src/manifest.ts index 955cf7308c..004ccfc77c 100644 --- a/packages/release-manifests/src/manifest.ts +++ b/packages/release-manifests/src/manifest.ts @@ -16,7 +16,9 @@ import fetch from 'cross-fetch'; -const VERSIONS_DOMAIN = 'https://versions.backstage.io'; +const VERSIONS_BASE_URL = 'https://versions.backstage.io'; +const GITHUB_RAW_BASE_URL = + 'https://raw.githubusercontent.com/backstage/versions/main'; /** * Contains mapping between Backstage release and package versions. @@ -35,6 +37,45 @@ export type GetManifestByVersionOptions = { version: string; }; +// Wait for waitMs, or until signal is aborted. +function wait(waitMs: number, signal: AbortSignal) { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + if (!signal.aborted) { + resolve(); + } + }, waitMs); + signal.addEventListener('abort', () => { + clearTimeout(timeout); + reject(new Error('Aborted')); + }); + }); +} + +// Run fn1 and then fn2 after fallbackDelayMs. Whichever one finishes +// first wins, and the other one is aborted through the provided signal. +export async function withFallback( + fn1: (signal: AbortSignal) => Promise, + fn2: (signal: AbortSignal) => Promise, + fallbackDelayMs: number, +): Promise { + const c1 = new AbortController(); + const c2 = new AbortController(); + + const promise1 = fn1(c1.signal).then(res => { + c2.abort(); + return res; + }); + const promise2 = wait(fallbackDelayMs, c2.signal) + .then(() => fn2(c2.signal)) + .then(res => { + c1.abort(); + return res; + }); + + return Promise.any([promise1, promise2]).catch(() => promise1); +} + /** * Returns a release manifest based on supplied version. * @public @@ -42,19 +83,27 @@ export type GetManifestByVersionOptions = { export async function getManifestByVersion( options: GetManifestByVersionOptions, ): Promise { - const url = `${VERSIONS_DOMAIN}/v1/releases/${encodeURIComponent( - options.version, - )}/manifest.json`; - const response = await fetch(url); - if (response.status === 404) { + const versionEnc = encodeURIComponent(options.version); + const res = await withFallback( + signal => + fetch(`${VERSIONS_BASE_URL}/v1/releases/${versionEnc}/manifest.json`, { + signal, + }), + signal => + fetch(`${GITHUB_RAW_BASE_URL}/v1/releases/${versionEnc}/manifest.json`, { + signal, + }), + 500, + ); + if (res.status === 404) { throw new Error(`No release found for ${options.version} version`); } - if (response.status !== 200) { + if (res.status !== 200) { throw new Error( - `Unexpected response status ${response.status} when fetching release from ${url}.`, + `Unexpected response status ${res.status} when fetching release from ${res.url}.`, ); } - return await response.json(); + return res.json(); } /** @@ -72,17 +121,31 @@ export type GetManifestByReleaseLineOptions = { export async function getManifestByReleaseLine( options: GetManifestByReleaseLineOptions, ): Promise { - const url = `${VERSIONS_DOMAIN}/v1/tags/${encodeURIComponent( - options.releaseLine, - )}/manifest.json`; - const response = await fetch(url); - if (response.status === 404) { + const releaseEnc = encodeURIComponent(options.releaseLine); + const res = await withFallback( + signal => + fetch(`${VERSIONS_BASE_URL}/v1/tags/${releaseEnc}/manifest.json`, { + signal, + }), + async signal => { + // The release tags are symlinks, which we need to follow manually when fetching from GitHub. + const baseUrl = `${GITHUB_RAW_BASE_URL}/v1/tags/${releaseEnc}`; + const linkRes = await fetch(baseUrl, { signal }); + if (!linkRes.ok) { + return linkRes; + } + const link = (await linkRes.text()).trim(); + return fetch(new URL(`${link}/manifest.json`, baseUrl), { signal }); + }, + 1000, + ); + if (res.status === 404) { throw new Error(`No '${options.releaseLine}' release line found`); } - if (response.status !== 200) { + if (res.status !== 200) { throw new Error( - `Unexpected response status ${response.status} when fetching release from ${url}.`, + `Unexpected response status ${res.status} when fetching release from ${res.url}.`, ); } - return await response.json(); + return res.json(); }