From 4b4a614c5c8601f388a879d1fd8c3c1b08d03419 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 19 May 2026 15:26:58 +0200 Subject: [PATCH 1/2] Guard cache eviction on identity and use createDeferred in test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only delete the cache entry on error if the map still holds the same promise, preventing a stale rejection from evicting a newer entry. Also switch the test to createDeferred for readability. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .../entrypoints/userInfo/CachedUserInfoService.test.ts | 9 ++++----- .../src/entrypoints/userInfo/CachedUserInfoService.ts | 4 +++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/backend-defaults/src/entrypoints/userInfo/CachedUserInfoService.test.ts b/packages/backend-defaults/src/entrypoints/userInfo/CachedUserInfoService.test.ts index b846a79200..66603788c6 100644 --- a/packages/backend-defaults/src/entrypoints/userInfo/CachedUserInfoService.test.ts +++ b/packages/backend-defaults/src/entrypoints/userInfo/CachedUserInfoService.test.ts @@ -19,6 +19,7 @@ import { UserInfoService, } from '@backstage/backend-plugin-api'; import { mockCredentials } from '@backstage/backend-test-utils'; +import { createDeferred } from '@backstage/types'; import { CachedUserInfoService, UserInfoCacheEntry, @@ -129,10 +130,8 @@ describe('CachedUserInfoService', () => { }); it('evicts eagerly so concurrent waiters see the rejection and the next call retries', async () => { - let rejectFirst: (error: Error) => void; - const firstCall = new Promise((_resolve, reject) => { - rejectFirst = reject; - }); + const firstCall = createDeferred(); + firstCall.catch(() => {}); const delegate: UserInfoService = { getUserInfo: jest @@ -146,7 +145,7 @@ describe('CachedUserInfoService', () => { const p1 = service.getUserInfo(creds); const p2 = service.getUserInfo(creds); - rejectFirst!(new Error('boom')); + firstCall.reject(new Error('boom')); await expect(p1).rejects.toThrow('boom'); await expect(p2).rejects.toThrow('boom'); diff --git a/packages/backend-defaults/src/entrypoints/userInfo/CachedUserInfoService.ts b/packages/backend-defaults/src/entrypoints/userInfo/CachedUserInfoService.ts index 7d26e3af70..ed36506aee 100644 --- a/packages/backend-defaults/src/entrypoints/userInfo/CachedUserInfoService.ts +++ b/packages/backend-defaults/src/entrypoints/userInfo/CachedUserInfoService.ts @@ -73,7 +73,9 @@ export class CachedUserInfoService implements UserInfoService { } const promise = this.#delegate.getUserInfo(credentials).catch(error => { - this.#entries.delete(token); + if (this.#entries.get(token)?.promise === promise) { + this.#entries.delete(token); + } throw error; }); From 97d3bd4c7bc7859062ff275493c97f0e013579fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fredrik=20Adel=C3=B6w?= Date: Tue, 19 May 2026 15:27:22 +0200 Subject: [PATCH 2/2] Add changeset for cached service cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Fredrik Adelöw --- .changeset/cached-service-cleanup.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/cached-service-cleanup.md diff --git a/.changeset/cached-service-cleanup.md b/.changeset/cached-service-cleanup.md new file mode 100644 index 0000000000..0f703a28ea --- /dev/null +++ b/.changeset/cached-service-cleanup.md @@ -0,0 +1,5 @@ +--- +'@backstage/backend-defaults': patch +--- + +Fixed a race condition in `CachedUserInfoService` where a failed request could incorrectly evict a newer cache entry for the same token. The error handler now verifies the map entry is still the same promise before deleting it.