release-manifests: add fallback that fetches from GitHub raw

Signed-off-by: Patrik Oldsberg <poldsberg@gmail.com>
This commit is contained in:
Patrik Oldsberg
2022-09-28 18:10:43 +02:00
parent 29434df216
commit a4496131fa
3 changed files with 137 additions and 18 deletions
+5
View File
@@ -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.
@@ -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<number>(() => {}));
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<number>((_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);
});
});
+80 -17
View File
@@ -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<void>((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<T>(
fn1: (signal: AbortSignal) => Promise<T>,
fn2: (signal: AbortSignal) => Promise<T>,
fallbackDelayMs: number,
): Promise<T> {
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<ReleaseManifest> {
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<ReleaseManifest> {
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();
}