feat(kubernetes-react): add optional getClusters() cache to KubernetesBackendClient (#34136)

fix(kubernetes-react): coalesce concurrent getClusters() fetches and validate TTL input

Signed-off-by: Rickard Dybeck <dybeck@spotify.com>
This commit is contained in:
Rickard Dybeck
2026-05-06 09:16:24 -04:00
committed by GitHub
parent 548a5558f5
commit e68cb8ac0f
4 changed files with 176 additions and 3 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-kubernetes-react': patch
---
Added optional clustersCacheTtlMs option to KubernetesBackendClient that caches getClusters() responses for the specified duration, avoiding repeated /clusters requests when multiple proxy calls resolve cluster auth in quick succession.
+1
View File
@@ -395,6 +395,7 @@ export class KubernetesBackendClient implements KubernetesApi {
discoveryApi: DiscoveryApi;
fetchApi: FetchApi;
kubernetesAuthProvidersApi: KubernetesAuthProvidersApi;
clustersCacheTtlMs?: number;
});
// (undocumented)
getCluster(clusterName: string): Promise<{
@@ -386,6 +386,120 @@ describe('KubernetesBackendClient', () => {
);
});
describe('getClusters caching', () => {
let fetchCount: number;
beforeEach(() => {
fetchCount = 0;
identityApi.getCredentials.mockResolvedValue({ token: 'idToken' });
worker.use(
http.get('http://localhost:1234/api/kubernetes/clusters', () => {
fetchCount++;
return HttpResponse.json({
items: [{ name: 'cluster-a', authProvider: 'aws' }],
});
}),
);
});
it('does not cache by default', async () => {
await backendClient.getClusters();
await backendClient.getClusters();
expect(fetchCount).toBe(2);
});
it('returns cached results within TTL when caching is enabled', async () => {
const cachingClient = new KubernetesBackendClient({
discoveryApi: UrlPatternDiscovery.compile(
'http://localhost:1234/api/{{ pluginId }}',
),
fetchApi,
kubernetesAuthProvidersApi,
clustersCacheTtlMs: 60_000,
});
const first = await cachingClient.getClusters();
const second = await cachingClient.getClusters();
expect(first).toEqual(second);
expect(fetchCount).toBe(1);
});
it('coalesces concurrent requests into a single fetch', async () => {
const cachingClient = new KubernetesBackendClient({
discoveryApi: UrlPatternDiscovery.compile(
'http://localhost:1234/api/{{ pluginId }}',
),
fetchApi,
kubernetesAuthProvidersApi,
clustersCacheTtlMs: 60_000,
});
const [first, second, third] = await Promise.all([
cachingClient.getClusters(),
cachingClient.getClusters(),
cachingClient.getClusters(),
]);
expect(first).toEqual(second);
expect(second).toEqual(third);
expect(fetchCount).toBe(1);
});
it('rejects negative TTL values', () => {
expect(
() =>
new KubernetesBackendClient({
discoveryApi: UrlPatternDiscovery.compile(
'http://localhost:1234/api/{{ pluginId }}',
),
fetchApi,
kubernetesAuthProvidersApi,
clustersCacheTtlMs: -1,
}),
).toThrow('clustersCacheTtlMs must be a finite number >= 0');
});
it('rejects NaN TTL values', () => {
expect(
() =>
new KubernetesBackendClient({
discoveryApi: UrlPatternDiscovery.compile(
'http://localhost:1234/api/{{ pluginId }}',
),
fetchApi,
kubernetesAuthProvidersApi,
clustersCacheTtlMs: NaN,
}),
).toThrow('clustersCacheTtlMs must be a finite number >= 0');
});
it('fetches again after TTL expires', async () => {
jest.useFakeTimers();
try {
const cachingClient = new KubernetesBackendClient({
discoveryApi: UrlPatternDiscovery.compile(
'http://localhost:1234/api/{{ pluginId }}',
),
fetchApi,
kubernetesAuthProvidersApi,
clustersCacheTtlMs: 1000,
});
await cachingClient.getClusters();
expect(fetchCount).toBe(1);
jest.advanceTimersByTime(1001);
await cachingClient.getClusters();
expect(fetchCount).toBe(2);
} finally {
jest.useRealTimers();
}
});
});
describe('proxy', () => {
beforeEach(() => {
worker.use(
@@ -31,15 +31,38 @@ export class KubernetesBackendClient implements KubernetesApi {
private readonly discoveryApi: DiscoveryApi;
private readonly fetchApi: FetchApi;
private readonly kubernetesAuthProvidersApi: KubernetesAuthProvidersApi;
private readonly clustersCacheTtlMs: number | undefined;
private clustersCache: { name: string; authProvider: string }[] | undefined;
private clustersCacheTimestamp = 0;
private clustersFetchPromise:
| Promise<{ name: string; authProvider: string }[]>
| undefined;
constructor(options: {
discoveryApi: DiscoveryApi;
fetchApi: FetchApi;
kubernetesAuthProvidersApi: KubernetesAuthProvidersApi;
/**
* When set, `getClusters()` results are cached for this many milliseconds.
* Useful when many proxy calls resolve cluster auth in quick succession.
*/
clustersCacheTtlMs?: number;
}) {
this.discoveryApi = options.discoveryApi;
this.fetchApi = options.fetchApi;
this.kubernetesAuthProvidersApi = options.kubernetesAuthProvidersApi;
if (
options.clustersCacheTtlMs !== undefined &&
!(
Number.isFinite(options.clustersCacheTtlMs) &&
options.clustersCacheTtlMs >= 0
)
) {
throw new Error(
`clustersCacheTtlMs must be a finite number >= 0, got ${options.clustersCacheTtlMs}`,
);
}
this.clustersCacheTtlMs = options.clustersCacheTtlMs;
}
private async handleResponse(response: Response): Promise<any> {
@@ -128,10 +151,40 @@ export class KubernetesBackendClient implements KubernetesApi {
}
async getClusters(): Promise<{ name: string; authProvider: string }[]> {
const url = `${await this.discoveryApi.getBaseUrl('kubernetes')}/clusters`;
const response = await this.fetchApi.fetch(url);
if (
this.clustersCacheTtlMs !== undefined &&
this.clustersCache &&
Date.now() - this.clustersCacheTimestamp < this.clustersCacheTtlMs
) {
return this.clustersCache;
}
return (await this.handleResponse(response)).items;
if (this.clustersFetchPromise) {
return this.clustersFetchPromise;
}
const fetchPromise = (async () => {
const url = `${await this.discoveryApi.getBaseUrl(
'kubernetes',
)}/clusters`;
const response = await this.fetchApi.fetch(url);
return (await this.handleResponse(response)).items;
})();
if (this.clustersCacheTtlMs !== undefined) {
this.clustersFetchPromise = fetchPromise;
}
try {
const clusters = await fetchPromise;
if (this.clustersCacheTtlMs !== undefined) {
this.clustersCache = clusters;
this.clustersCacheTimestamp = Date.now();
}
return clusters;
} finally {
this.clustersFetchPromise = undefined;
}
}
async proxy(options: {