From e35042999f7d477ed1da59f43ef03605763ac2bf Mon Sep 17 00:00:00 2001 From: Ali Elkhateeb Date: Mon, 4 May 2026 16:10:05 +0300 Subject: [PATCH] fix(core): Add timeout to external secrets provider refresh (#29679) --- .../__tests__/secrets-cache.service.test.ts | 17 ++++++++++++ .../modules/external-secrets.ee/constants.ts | 1 + .../secrets-cache.service.ts | 26 +++++++++++++++++-- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/modules/external-secrets.ee/__tests__/secrets-cache.service.test.ts b/packages/cli/src/modules/external-secrets.ee/__tests__/secrets-cache.service.test.ts index 1948ad4da0c..22f1b973ab0 100644 --- a/packages/cli/src/modules/external-secrets.ee/__tests__/secrets-cache.service.test.ts +++ b/packages/cli/src/modules/external-secrets.ee/__tests__/secrets-cache.service.test.ts @@ -2,6 +2,7 @@ import { mockLogger } from '@n8n/backend-test-utils'; import { AnotherDummyProvider, DummyProvider } from '@test/external-secrets/utils'; +import { EXTERNAL_SECRETS_REFRESH_TIMEOUT_MS } from '../constants'; import { ExternalSecretsProviderRegistry } from '../provider-registry.service'; import { ExternalSecretsSecretsCache } from '../secrets-cache.service'; @@ -53,6 +54,22 @@ describe('SecretsCache', () => { await expect(cache.refreshProvider('dummy', dummyProvider)).resolves.not.toThrow(); }); + + it('should not hang when update exceeds refresh timeout', async () => { + jest.useFakeTimers(); + try { + jest + .spyOn(dummyProvider, 'update') + .mockImplementation(async () => await new Promise(() => {})); + + const refreshPromise = cache.refreshProvider('dummy', dummyProvider); + await jest.advanceTimersByTimeAsync(EXTERNAL_SECRETS_REFRESH_TIMEOUT_MS); + + await expect(refreshPromise).resolves.toBeUndefined(); + } finally { + jest.useRealTimers(); + } + }); }); describe('refreshAll', () => { diff --git a/packages/cli/src/modules/external-secrets.ee/constants.ts b/packages/cli/src/modules/external-secrets.ee/constants.ts index 97bd6bdb8f0..3abaf2b3241 100644 --- a/packages/cli/src/modules/external-secrets.ee/constants.ts +++ b/packages/cli/src/modules/external-secrets.ee/constants.ts @@ -3,6 +3,7 @@ import type { INodeProperties } from 'n8n-workflow'; export const EXTERNAL_SECRETS_DB_KEY = 'feature.externalSecrets'; export const EXTERNAL_SECRETS_INITIAL_BACKOFF = 10 * 1000; export const EXTERNAL_SECRETS_MAX_BACKOFF = 5 * 60 * 1000; +export const EXTERNAL_SECRETS_REFRESH_TIMEOUT_MS = 20_000; export const DOCS_HELP_NOTICE: INodeProperties = { displayName: diff --git a/packages/cli/src/modules/external-secrets.ee/secrets-cache.service.ts b/packages/cli/src/modules/external-secrets.ee/secrets-cache.service.ts index b919230e9f3..2acea754463 100644 --- a/packages/cli/src/modules/external-secrets.ee/secrets-cache.service.ts +++ b/packages/cli/src/modules/external-secrets.ee/secrets-cache.service.ts @@ -1,7 +1,8 @@ import { Logger } from '@n8n/backend-common'; import { Service } from '@n8n/di'; -import { ensureError } from 'n8n-workflow'; +import { ensureError, OperationalError } from 'n8n-workflow'; +import { EXTERNAL_SECRETS_REFRESH_TIMEOUT_MS } from './constants'; import { ExternalSecretsProviderRegistry } from './provider-registry.service'; import type { SecretsProvider } from './types'; @@ -41,7 +42,7 @@ export class ExternalSecretsSecretsCache { } try { - await provider.update(); + await this.refreshProviderWithTimeout(provider); this.logger.debug(`Refreshed secrets from provider ${name}`); } catch (error) { this.logger.error(`Error refreshing secrets from provider ${name}`, { @@ -50,6 +51,27 @@ export class ExternalSecretsSecretsCache { } } + private async refreshProviderWithTimeout(provider: SecretsProvider): Promise { + let timeoutId: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject( + new OperationalError( + `Timed out refreshing secrets after ${EXTERNAL_SECRETS_REFRESH_TIMEOUT_MS}ms`, + ), + ); + }, EXTERNAL_SECRETS_REFRESH_TIMEOUT_MS); + }); + + try { + await Promise.race([provider.update(), timeoutPromise]); + } finally { + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } + } + /** * Get a secret from a specific provider */