From e68cb8ac0f9cdc78db51aca2d470a93c868548cc Mon Sep 17 00:00:00 2001 From: Rickard Dybeck Date: Wed, 6 May 2026 09:16:24 -0400 Subject: [PATCH] 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 --- .changeset/many-tools-take.md | 5 + plugins/kubernetes-react/report.api.md | 1 + .../src/api/KubernetesBackendClient.test.ts | 114 ++++++++++++++++++ .../src/api/KubernetesBackendClient.ts | 59 ++++++++- 4 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 .changeset/many-tools-take.md diff --git a/.changeset/many-tools-take.md b/.changeset/many-tools-take.md new file mode 100644 index 0000000000..dca11a68de --- /dev/null +++ b/.changeset/many-tools-take.md @@ -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. diff --git a/plugins/kubernetes-react/report.api.md b/plugins/kubernetes-react/report.api.md index e9631fac38..e4f94ba967 100644 --- a/plugins/kubernetes-react/report.api.md +++ b/plugins/kubernetes-react/report.api.md @@ -395,6 +395,7 @@ export class KubernetesBackendClient implements KubernetesApi { discoveryApi: DiscoveryApi; fetchApi: FetchApi; kubernetesAuthProvidersApi: KubernetesAuthProvidersApi; + clustersCacheTtlMs?: number; }); // (undocumented) getCluster(clusterName: string): Promise<{ diff --git a/plugins/kubernetes-react/src/api/KubernetesBackendClient.test.ts b/plugins/kubernetes-react/src/api/KubernetesBackendClient.test.ts index 3e8b7aa437..85181c2394 100644 --- a/plugins/kubernetes-react/src/api/KubernetesBackendClient.test.ts +++ b/plugins/kubernetes-react/src/api/KubernetesBackendClient.test.ts @@ -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( diff --git a/plugins/kubernetes-react/src/api/KubernetesBackendClient.ts b/plugins/kubernetes-react/src/api/KubernetesBackendClient.ts index f0bd0fb945..5565d2ad2a 100644 --- a/plugins/kubernetes-react/src/api/KubernetesBackendClient.ts +++ b/plugins/kubernetes-react/src/api/KubernetesBackendClient.ts @@ -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 { @@ -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: {