From bef01cbb8fdcdaafd17c8b251407554a5a559c11 Mon Sep 17 00:00:00 2001 From: James Xian Date: Thu, 29 Oct 2026 16:15:48 -0700 Subject: [PATCH] Align credential acquisition timeouts to five seconds Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- TECHNICAL.md | 5 +++-- dotnet/README.md | 2 +- dotnet/Src/CredentialTokenService.cs | 2 +- dotnet/tests/CredentialAcquisitionTests.cs | 16 +++++++++++++++- javascript/README.md | 2 +- javascript/src/functions/credentials.js | 2 +- javascript/test/credentials.test.js | 14 ++++++++++++++ python/README.md | 2 +- python/src/credentials.py | 2 +- python/tests/test_credentials.py | 14 ++++++++++++++ setup/docs/Troubleshooting.md | 4 ++++ 11 files changed, 56 insertions(+), 9 deletions(-) diff --git a/TECHNICAL.md b/TECHNICAL.md index 38a05ae..d6b21e1 100644 --- a/TECHNICAL.md +++ b/TECHNICAL.md @@ -283,8 +283,9 @@ All runtimes acquire provider credentials on demand for each live request: another token exchange. Managed-identity assertions are obtained only when the SDK needs them. There are no application-owned provider credential caches, startup warmup, background refresh -loops, or failure cooldowns. Evaluation skips provider credential work. Acquisition retains its -2.5-second budget. A failure returns `502` with a request-correlated `credential_unavailable` +loops, or failure cooldowns. Evaluation skips provider credential work. Acquisition has a +5-second budget in all runtimes, separate from the provider +HTTP timeout. A failure returns `502` with a request-correlated `credential_unavailable` log; the next live request can attempt acquisition again. `EPP_KEY_VAULT_CACHE_ENABLED` and `EPP_ACCESS_TOKEN_CACHE_ENABLED` are obsolete and ignored by diff --git a/dotnet/README.md b/dotnet/README.md index f019b43..2ed12bc 100644 --- a/dotnet/README.md +++ b/dotnet/README.md @@ -93,7 +93,7 @@ mapping. Each provider maps its statuses to `ProviderResult`. Request-aware rout accounts require a separate trust, configuration, credential, and test design. `CredentialTokenService` invokes the selected provider's credential retrieval for each live -request with a 2.5-second cancellation budget. API-key secrets are read from Key Vault each time. +request with a 5-second cancellation budget. API-key secrets are read from Key Vault each time. The OAuth providers share `OAuthCredentialSource` and reuse Azure Identity SDK credential instances, leaving token caching and renewal to the SDK. There is no custom credential cache, hosted warmup service, polling, or retry cooldown. Evaluation does no provider credential work. See diff --git a/dotnet/Src/CredentialTokenService.cs b/dotnet/Src/CredentialTokenService.cs index 43034bd..a4af98c 100644 --- a/dotnet/Src/CredentialTokenService.cs +++ b/dotnet/Src/CredentialTokenService.cs @@ -4,7 +4,7 @@ namespace Epp.Otp; public sealed class CredentialTokenService { - internal static readonly TimeSpan AcquisitionTimeout = TimeSpan.FromSeconds(2.5); + internal static readonly TimeSpan AcquisitionTimeout = TimeSpan.FromSeconds(5); public async Task GetCredentialsAsync( PhoneProviderBase provider, diff --git a/dotnet/tests/CredentialAcquisitionTests.cs b/dotnet/tests/CredentialAcquisitionTests.cs index d699258..7be9c68 100644 --- a/dotnet/tests/CredentialAcquisitionTests.cs +++ b/dotnet/tests/CredentialAcquisitionTests.cs @@ -70,6 +70,20 @@ public async Task CallerCancellationCancelsOnlyItsCredentialFetch() Assert.True(observed.IsCancellationRequested); } + [Fact] + public async Task AcquisitionAllowsCredentialsThatTakeLongerThanThePreviousBudget() + { + Assert.Equal(TimeSpan.FromSeconds(5), CredentialTokenService.AcquisitionTimeout); + var provider = new TestProvider(async cancellation => + { + await Task.Delay(TimeSpan.FromSeconds(3), cancellation); + return new("apiKey", Secret: "synthetic-secret"); + }); + + var credentials = await new CredentialTokenService().GetCredentialsAsync(provider, new AppConfig()); + Assert.Equal("synthetic-secret", credentials.Secret); + } + [Fact] public async Task AcquisitionDeadlineRejectsLateResultsWithoutBlockingTheNextRequest() { @@ -88,7 +102,7 @@ public async Task AcquisitionDeadlineRejectsLateResultsWithoutBlockingTheNextReq var error = await Assert.ThrowsAsync(() => service.GetCredentialsAsync(provider, new AppConfig())); Assert.Equal("provider credential unavailable", error.Message); - Assert.InRange(elapsed.Elapsed.TotalSeconds, 2, 8); + Assert.InRange(elapsed.Elapsed.TotalSeconds, 4.5, 10); Assert.True(observed.IsCancellationRequested); } finally { late.TrySetResult(new("apiKey", Secret: "late")); } diff --git a/javascript/README.md b/javascript/README.md index e273807..466cc92 100644 --- a/javascript/README.md +++ b/javascript/README.md @@ -84,7 +84,7 @@ For diagnostics, use [Application Insights](../docs/APPLICATION-INSIGHTS.md) and Each live request reads API-key secrets from Key Vault or calls the reusable Azure Identity credential's token API. Only the SDK caches tokens and renews them as needed; there is no custom credential cache, startup warmup, polling, or retry cooldown. Evaluation does no provider credential -work. Acquisition is bounded to 2.5 seconds, and shutdown aborts pending requests. See +work. Acquisition is bounded to 5 seconds, and shutdown aborts pending requests. See [credential acquisition](../TECHNICAL.md#provider-settings-and-credential-acquisition). | Source | Purpose | diff --git a/javascript/src/functions/credentials.js b/javascript/src/functions/credentials.js index 0b764c4..86a41e5 100644 --- a/javascript/src/functions/credentials.js +++ b/javascript/src/functions/credentials.js @@ -11,7 +11,7 @@ const { AzureLogger } = require('@azure/logger'); const API_KEY_MODE = 'apiKey'; const OAUTH_MODE = 'oauth'; const TOKEN_EXCHANGE_SCOPE = 'api://AzureADTokenExchange/.default'; -const ACQUISITION_TIMEOUT_MS = 2500; +const ACQUISITION_TIMEOUT_MS = 5000; const TOKEN_SKEW_MS = 30000; const unavailable = (error) => new Error(aadstsCode(error) || 'provider credential unavailable'); /** @type {AsyncLocalStorage} */ diff --git a/javascript/test/credentials.test.js b/javascript/test/credentials.test.js index 44fec88..b8a7653 100644 --- a/javascript/test/credentials.test.js +++ b/javascript/test/credentials.test.js @@ -69,6 +69,20 @@ test('concurrent secret requests are independent and shutdown aborts all waiters } finally { release(); manager.close(); } }); +test('credential acquisition allows a three-second response within a five-second budget', async (t) => { + const setTimer = global.setTimeout; + const timers = t.mock.method(global, 'setTimeout', setTimer); + t.mock.method(SecretClient.prototype, 'getSecret', async () => { + await new Promise((resolve) => setTimer(resolve, 3000)); + return { value: 'synthetic-secret' }; + }); + const manager = new CredentialTokenService(); + try { + assert.equal((await manager.getCredentials(auth, config)).secret, 'synthetic-secret'); + assert.equal(timers.mock.calls[0].arguments[1], 5000); + } finally { manager.close(); } +}); + test('empty, disabled, future, or expired secrets fail instead of returning old credentials', async (t) => { const vault = t.mock.method(SecretClient.prototype, 'getSecret', async () => ({ value: 'valid' })); for (const invalid of [ diff --git a/python/README.md b/python/README.md index 81815ed..55d5094 100644 --- a/python/README.md +++ b/python/README.md @@ -83,7 +83,7 @@ Verify which Python logging properties the host/export pipeline preserves. Each live request reads API-key secrets from Key Vault or calls the reusable Azure Identity credential's token API. Only the SDK caches tokens and renews them as needed; there is no custom credential cache, startup warmup, polling, or retry cooldown. Evaluation does no provider credential -work. Request-scoped daemon threads bound the caller's acquisition wait to 2.5 seconds without +work. Request-scoped daemon threads bound the caller's acquisition wait to 5 seconds without blocking process shutdown. Timed-out results are discarded; SDK network timeouts bound the underlying calls. See [credential acquisition](../TECHNICAL.md#provider-settings-and-credential-acquisition). diff --git a/python/src/credentials.py b/python/src/credentials.py index b8a42e5..af1e978 100644 --- a/python/src/credentials.py +++ b/python/src/credentials.py @@ -19,7 +19,7 @@ OAUTH_MODE: Final = "oauth" TOKEN_EXCHANGE_SCOPE = "api://AzureADTokenExchange/.default" CREDENTIAL_ERROR = "provider credential unavailable" -ACQUISITION_TIMEOUT_SECONDS = 2.5 +ACQUISITION_TIMEOUT_SECONDS = 5 TOKEN_SKEW_SECONDS = 30 _acquiring = ContextVar("epp_credential_acquisition", default=False) T = TypeVar("T") diff --git a/python/tests/test_credentials.py b/python/tests/test_credentials.py index 7797c35..9cdd3ae 100644 --- a/python/tests/test_credentials.py +++ b/python/tests/test_credentials.py @@ -114,6 +114,20 @@ def test_acquisition_timeout_discards_late_values_and_does_not_delay_the_next_re manager.close() +def test_default_acquisition_budget_allows_a_three_second_response(): + assert credentials_module.ACQUISITION_TIMEOUT_SECONDS == 5 + + def read(_): + time.sleep(3) + return "synthetic-secret" + + manager = CredentialTokenService(Mock(resolve=read)) + try: + assert manager.resolve(AUTH, CONFIG)["secret"] == "synthetic-secret" + finally: + manager.close() + + @pytest.mark.parametrize("empty_secret", ["key", "id"]) def test_empty_secrets_do_not_return_a_previous_or_partial_bundle(empty_secret): secrets = Mock(resolve=Mock(return_value="valid")) diff --git a/setup/docs/Troubleshooting.md b/setup/docs/Troubleshooting.md index 50e137c..7c06b83 100644 --- a/setup/docs/Troubleshooting.md +++ b/setup/docs/Troubleshooting.md @@ -170,6 +170,10 @@ federation, provider tenant/scope, and provider permissions using the Match diagnostics to the deployed package version. Regardless of event names, verify permissions and identity configuration. +Credential acquisition is bounded to 5 seconds in all runtimes. +`EPP_PROVIDER_TIMEOUT_MS` controls the later provider HTTP call, not credential acquisition. +Deploy the updated runtime package to use the longer budget; changing app settings alone is not enough. + Current packages acquire credentials only during live requests, retain SDK token caching, and have no custom failure cooldown. The next live request can retry acquisition. Confirm the deployed package version before comparing event sequences. If moving to current behavior, upgrade through