From ee91424fe13883ebb245adc1d7cf6c443c812ec7 Mon Sep 17 00:00:00 2001 From: Dylan Date: Wed, 18 Mar 2026 12:50:57 +1000 Subject: [PATCH 1/2] Use caching header for stale checks --- .../OctopusFeatureContextProviderTests.cs | 38 +++---- .../OctopusFeatureContextTests.cs | 18 +-- .../OctopusFeatureClient.cs | 104 +++--------------- .../OctopusFeatureContext.cs | 7 +- .../OctopusFeatureContextProvider.cs | 34 +++--- 5 files changed, 60 insertions(+), 141 deletions(-) diff --git a/src/Octopus.OpenFeature.Provider.Tests/OctopusFeatureContextProviderTests.cs b/src/Octopus.OpenFeature.Provider.Tests/OctopusFeatureContextProviderTests.cs index 429fe32..8184e10 100644 --- a/src/Octopus.OpenFeature.Provider.Tests/OctopusFeatureContextProviderTests.cs +++ b/src/Octopus.OpenFeature.Provider.Tests/OctopusFeatureContextProviderTests.cs @@ -1,3 +1,4 @@ +using System.Net.Http.Headers; using FluentAssertions; using FluentAssertions.Execution; using Microsoft.Extensions.Logging; @@ -17,12 +18,7 @@ class MockOctopusFeatureClient(FeatureToggles? featureToggles) : IOctopusFeature { FeatureToggles? featureToggles = featureToggles; - public Task HaveFeaturesChanged(byte[] contentHash, CancellationToken cancellationToken) - { - return Task.FromResult(true); - } - - public Task GetFeatureToggleEvaluationManifest(CancellationToken cancellationToken) + public Task GetLatestManifest(EntityTagHeaderValue? eTag, CancellationToken cancellationToken) { return Task.FromResult(featureToggles); } @@ -42,17 +38,15 @@ public void WhenInstantiated_ProvidesAnEmptyEvaluationContext() using var scope = new AssertionScope(); context.Should().NotBeNull(); - context.ContentHash.Length.Should().Be(0); + context.ETag.Should().BeNull(); } [Fact] public async Task WhenInitialized_ProvidesRetrievedEvaluationContext() { - byte[] contentHash = [0x01, 0x02, 0x03, 0x04]; - var client = new MockOctopusFeatureClient(new FeatureToggles( [new FeatureToggleEvaluation("Test Feature", "test-feature", true, [])], - contentHash)); + new("01-02-03-04"))); var provider = new OctopusFeatureContextProvider(configuration, client, NullLogger.Instance); await provider.Initialize(); @@ -60,18 +54,17 @@ [new FeatureToggleEvaluation("Test Feature", "test-feature", true, [])], using var scope = new AssertionScope(); context.Should().NotBeNull(); - context.ContentHash.Should().BeEquivalentTo(contentHash); + context.ETag.Should().NotBeNull(); + context.ETag!.Tag.Should().Be("01-02-03-04"); context.Evaluate("test-feature", false, context: null).Value.Should().BeTrue(); } [Fact] public async Task WhenInitialized_RefreshesCacheAfterCacheDurationExpires() { - byte[] contentHash = [0x01, 0x02, 0x03, 0x04]; - var client = new MockOctopusFeatureClient(new FeatureToggles( [new FeatureToggleEvaluation("Test Feature", "test-feature", true, [])], - contentHash)); + new("01-02-03-04"))); // Initialize the provider var provider = new OctopusFeatureContextProvider(configuration, client, NullLogger.Instance); @@ -80,31 +73,28 @@ [new FeatureToggleEvaluation("Test Feature", "test-feature", true, [])], // Validate the initial state using var scope = new AssertionScope(); var context = provider.GetEvaluationContext(); - context.ContentHash.Should().BeEquivalentTo(contentHash); + context.ETag.Should().NotBeNull(); + context.ETag!.Tag.Should().Be("01-02-03-04"); context.Evaluate("test-feature", false, context: null).Value.Should().BeTrue(); // Simulate a change in the available feature toggles client.ChangeToggles(new FeatureToggles( [new FeatureToggleEvaluation("Test Feature", "test-feature", false, [])], - [0x01, 0x02, 0x03, 0x05])); + new("01-02-03-05"))); // Wait for the cache to expire await Task.Delay(TimeSpan.FromSeconds(5)); // Validate the updated toggles are available context = provider.GetEvaluationContext(); - context.ContentHash.Should().BeEquivalentTo(new byte[] { 0x01, 0x02, 0x03, 0x05 }); + context.ETag.Should().NotBeNull(); + context.ETag!.Tag.Should().Be("01-02-03-05"); context.Evaluate("test-feature", false, context: null).Value.Should().BeFalse(); } class AlwaysFailsFeatureClient : IOctopusFeatureClient { - public Task HaveFeaturesChanged(byte[] contentHash, CancellationToken cancellationToken) - { - return Task.FromResult(true); - } - - public Task GetFeatureToggleEvaluationManifest(CancellationToken cancellationToken) + public Task GetLatestManifest(EntityTagHeaderValue? eTag, CancellationToken cancellationToken) { throw new Exception("Oops!"); } @@ -120,7 +110,7 @@ public async Task WhenFeatureEvaluationRetrievalFails_LogsError() await provider.Initialize(); using var scope = new AssertionScope(); - provider.GetEvaluationContext().ContentHash.Length.Should().Be(0); + provider.GetEvaluationContext().ETag.Should().BeNull(); logger.LatestRecord.Level.Should().Be(LogLevel.Error); logger.LatestRecord.Message.Should().StartWith("Failed to retrieve feature manifest"); diff --git a/src/Octopus.OpenFeature.Provider.Tests/OctopusFeatureContextTests.cs b/src/Octopus.OpenFeature.Provider.Tests/OctopusFeatureContextTests.cs index 853a2ac..2d047c1 100644 --- a/src/Octopus.OpenFeature.Provider.Tests/OctopusFeatureContextTests.cs +++ b/src/Octopus.OpenFeature.Provider.Tests/OctopusFeatureContextTests.cs @@ -13,7 +13,7 @@ public void EvaluatesToTrue_IfFeatureIsContainedWithinTheSet_AndFeatureIsEnabled { var featureToggles = new FeatureToggles([ new FeatureToggleEvaluation("testfeature", "test-feature", true, []) - ], []); + ], null); var context = new OctopusFeatureContext(featureToggles, NullLoggerFactory.Instance); @@ -27,7 +27,7 @@ public void WhenEvaluatedWithCasingDifferences_EvaluationIsInsensitiveToCase() { var featureToggles = new FeatureToggles([ new FeatureToggleEvaluation("testfeature", "test-feature", true, []) - ], []); + ], null); var context = new OctopusFeatureContext(featureToggles, NullLoggerFactory.Instance); @@ -41,7 +41,7 @@ public void EvaluatesToFalse_IfFeatureIsContainedWithinTheSet_AndFeatureIsNotEna { var featureToggles = new FeatureToggles([ new FeatureToggleEvaluation("testfeature", "test-feature", false, []) - ], []); + ], null); var context = new OctopusFeatureContext(featureToggles, NullLoggerFactory.Instance); @@ -55,7 +55,7 @@ public void GivenAFlagKeyThatIsNotASlug_ReturnsFlagNotFound_AndEvaluatesToDefaul { var featureToggles = new FeatureToggles([ new FeatureToggleEvaluation("This is clearly not a slug!", "this-is-clearly-not-a-slug", true, []) - ], []); + ], null); var context = new OctopusFeatureContext(featureToggles, NullLoggerFactory.Instance); @@ -70,7 +70,7 @@ public void EvaluatesToDefaultValue_IfFeatureIsNotContainedWithinSet() { var featureToggles = new FeatureToggles([ new FeatureToggleEvaluation("testfeature", "testfeature", true, []) - ], []); + ], null); var context = new OctopusFeatureContext(featureToggles, NullLoggerFactory.Instance); @@ -97,7 +97,7 @@ public void { var featureToggles = new FeatureToggles([ new FeatureToggleEvaluation("testfeature", "testfeature", true, [new("license", "trial")]) - ], []); + ], null); var context = new OctopusFeatureContext(featureToggles, NullLoggerFactory.Instance); @@ -113,7 +113,7 @@ public void { var featureToggles = new FeatureToggles([ new FeatureToggleEvaluation("testfeature", "testfeature", true, []) - ], []); + ], null); var context = new OctopusFeatureContext(featureToggles, NullLoggerFactory.Instance); @@ -131,7 +131,7 @@ public void WhenAFeatureIsToggledOnForMultipleSegments_EvaluatesCorrectly() new("region", "au"), new("region", "us"), ]) - ], []); + ], null); var context = new OctopusFeatureContext(featureToggles, NullLoggerFactory.Instance); @@ -170,7 +170,7 @@ public void { var featureToggles = new FeatureToggles([ new FeatureToggleEvaluation("testfeature", "testfeature", true, [new("license", "trial")]) - ], []); + ], null); var context = new OctopusFeatureContext(featureToggles, NullLoggerFactory.Instance); diff --git a/src/Octopus.OpenFeature.Provider/OctopusFeatureClient.cs b/src/Octopus.OpenFeature.Provider/OctopusFeatureClient.cs index 02ae316..4f43b82 100644 --- a/src/Octopus.OpenFeature.Provider/OctopusFeatureClient.cs +++ b/src/Octopus.OpenFeature.Provider/OctopusFeatureClient.cs @@ -5,11 +5,11 @@ namespace Octopus.OpenFeature.Provider; -public class FeatureToggles(FeatureToggleEvaluation[] evaluations, byte[] contentHash) +class FeatureToggles(FeatureToggleEvaluation[] evaluations, EntityTagHeaderValue? eTag) { - public FeatureToggleEvaluation[] Evaluations { get; } = evaluations; + internal FeatureToggleEvaluation[] Evaluations { get; } = evaluations; - public byte[] ContentHash { get; } = contentHash; + internal EntityTagHeaderValue? ETag { get; } = eTag; } public class FeatureToggleEvaluation(string name, string slug, bool isEnabled, KeyValuePair[] segments) @@ -25,8 +25,7 @@ public class FeatureToggleEvaluation(string name, string slug, bool isEnabled, K interface IOctopusFeatureClient { - Task HaveFeaturesChanged(byte[] contentHash, CancellationToken cancellationToken); - Task GetFeatureToggleEvaluationManifest(CancellationToken cancellationToken); + Task GetLatestManifest(EntityTagHeaderValue? eTag, CancellationToken cancellationToken); } /// @@ -34,55 +33,14 @@ interface IOctopusFeatureClient /// class OctopusFeatureClient(OctopusFeatureConfiguration configuration, ILogger logger) : IOctopusFeatureClient { - public async Task HaveFeaturesChanged(byte[] contentHash, CancellationToken cancellationToken) - { - if (contentHash.Length == 0) - { - return true; - } - - var client = new HttpClient - { - BaseAddress = configuration.ServerUri - }; - client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(configuration.ProductMetadata.ProductHeaderValue)); - - FeatureCheck? hash = null; - client.DefaultRequestHeaders.Add("Authorization", $"Bearer {configuration.ClientIdentifier}"); - - var result = await ExecuteWithRetry(async ct => await client.GetAsync("api/featuretoggles/check/v3/", ct), cancellationToken); - - if (result is not null && result.IsSuccessStatusCode) - { - var rawResult = await result.Content.ReadAsStringAsync(); - - hash = JsonSerializer.Deserialize(rawResult, JsonSerializerOptions.Web); - } - - if (hash is null) - { - logger.LogWarning("Failed to retrieve feature toggles after 3 retries. Previously retrieved feature toggle values will continue to be used."); - return false; - } - - var haveFeaturesChanged = !hash.ContentHash.SequenceEqual(contentHash); - - return haveFeaturesChanged; - } - - class FeatureCheck(byte[] contentHash) - { - public byte[] ContentHash { get; } = contentHash; - } - /// /// Retrieves the evaluated feature set from OctoToggle for a given installation and project. /// This method will return null if: - /// - Toggles are not found for the installation and id - /// - We don't receive a ContentHash header - /// - We cannot deserialize the content response into a OctoToggleFeatureManifest + /// - The toggles have not changed since the last request. + /// - Toggles are not found for the installation and id. + /// - We cannot deserialize the content response into a OctoToggleFeatureManifest. /// - public async Task GetFeatureToggleEvaluationManifest(CancellationToken cancellationToken) + public async Task GetLatestManifest(EntityTagHeaderValue? eTag, CancellationToken cancellationToken) { var client = new HttpClient { @@ -97,7 +55,12 @@ class FeatureCheck(byte[] contentHash) client.DefaultRequestHeaders.Add("Authorization", $"Bearer {configuration.ClientIdentifier}"); - var response = await ExecuteWithRetry(async ct => await client.GetAsync("api/featuretoggles/v3/", ct), cancellationToken); + if (eTag is not null) + { + client.DefaultRequestHeaders.IfNoneMatch.Add(eTag); + } + + var response = await client.GetAsync("api/featuretoggles/v3/", cancellationToken); if (response is null or { StatusCode: HttpStatusCode.NotFound }) { @@ -105,23 +68,12 @@ class FeatureCheck(byte[] contentHash) return null; } - if (!response.Headers.TryGetValues("ContentHash", out IEnumerable values)) + if (response.StatusCode == HttpStatusCode.NotModified) { - logger.LogWarning("Feature toggle response from {OctoToggleUrl} did not contain expected ContentHash header", configuration.ServerUri); return null; } - var headerValues = values.ToArray(); - if (!headerValues.Any()) - { - logger.LogWarning("Feature toggle response from {OctoToggleUrl} returned an empty ContentHash header", configuration.ServerUri); - return null; - } - var rawContentHash = headerValues.First(); - // WARNING: v2 and v3 endpoints have identical response contracts. - // If for any reason the v3 endpoint response contract starts to diverge from the v2 contract, - // This code will need to update accordingly - var result = await response.Content.ReadAsStringAsync(); + var result = await response.Content.ReadAsStreamAsync(); var evaluations = JsonSerializer.Deserialize(result, JsonSerializerOptions.Web); @@ -131,28 +83,6 @@ class FeatureCheck(byte[] contentHash) return null; } - var toggles = new FeatureToggles(evaluations, Convert.FromBase64String(rawContentHash)); - - return toggles; - } - - async Task ExecuteWithRetry(Func> callback, CancellationToken cancellationToken) - { - var attempts = 0; - while (attempts < 3) - { - try - { - return await callback(cancellationToken); - } - catch (Exception e) - { - attempts++; - await Task.Delay(TimeSpan.FromSeconds(Math.Pow(2, attempts)), cancellationToken); - logger.LogTrace(e, "Error occurred retrieving feature toggles from {OctoToggleUrl}. Retrying (attempt {attempt} out of 3).", configuration.ServerUri, attempts); - } - } - - return default; + return new FeatureToggles(evaluations, response.Headers.ETag); } } diff --git a/src/Octopus.OpenFeature.Provider/OctopusFeatureContext.cs b/src/Octopus.OpenFeature.Provider/OctopusFeatureContext.cs index 8df279f..96919af 100644 --- a/src/Octopus.OpenFeature.Provider/OctopusFeatureContext.cs +++ b/src/Octopus.OpenFeature.Provider/OctopusFeatureContext.cs @@ -1,4 +1,5 @@ -using System.Text.RegularExpressions; +using System.Net.Http.Headers; +using System.Text.RegularExpressions; using Microsoft.Extensions.Logging; using OpenFeature.Constant; using OpenFeature.Model; @@ -7,13 +8,13 @@ namespace Octopus.OpenFeature.Provider; partial class OctopusFeatureContext(FeatureToggles toggles, ILoggerFactory loggerFactory) { - public byte[] ContentHash => toggles.ContentHash; + public EntityTagHeaderValue? ETag => toggles.ETag; readonly Regex expression = new("^([a-z0-9]+(-[a-z0-9]+)*)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); readonly ILogger logger = loggerFactory.CreateLogger(); public static OctopusFeatureContext Empty(ILoggerFactory loggerFactory) { - return new OctopusFeatureContext(new FeatureToggles([], []), loggerFactory); + return new OctopusFeatureContext(new FeatureToggles([], null), loggerFactory); } public ResolutionDetails Evaluate(string slug, bool defaultValue, EvaluationContext? context) diff --git a/src/Octopus.OpenFeature.Provider/OctopusFeatureContextProvider.cs b/src/Octopus.OpenFeature.Provider/OctopusFeatureContextProvider.cs index f0135b3..672445d 100644 --- a/src/Octopus.OpenFeature.Provider/OctopusFeatureContextProvider.cs +++ b/src/Octopus.OpenFeature.Provider/OctopusFeatureContextProvider.cs @@ -16,7 +16,6 @@ class OctopusFeatureContextProvider( Task? evaluationContextRefreshTask; bool initialized; int retryAttempt; - readonly TimeSpan retryDelay = TimeSpan.FromSeconds(5); public OctopusFeatureContext GetEvaluationContext() { @@ -32,11 +31,7 @@ public async Task Initialize() try { - var toggles = await client.GetFeatureToggleEvaluationManifest(cancellationTokenSource.Token); - currentContext = - toggles is not null - ? new OctopusFeatureContext(toggles, configuration.LoggerFactory) - : OctopusFeatureContext.Empty(configuration.LoggerFactory); + await FetchToggles(cancellationTokenSource.Token); } catch (Exception e) { @@ -60,18 +55,9 @@ async Task RefreshEvaluationContext(CancellationToken cancellationToken) { try { - await Task.Delay(delay, cancellationToken); + await Task.Delay(configuration.CacheDuration, cancellationToken); + await FetchToggles(cancellationToken); - if (await client.HaveFeaturesChanged(currentContext.ContentHash, cancellationToken)) - { - var toggles = await client.GetFeatureToggleEvaluationManifest(cancellationToken); - currentContext = - toggles is not null - ? new OctopusFeatureContext(toggles, configuration.LoggerFactory) - : OctopusFeatureContext.Empty(configuration.LoggerFactory); - } - - delay = configuration.CacheDuration; retryAttempt = 0; } catch (OperationCanceledException) @@ -81,12 +67,24 @@ toggles is not null catch (Exception e) { logger.LogError(e, "{FailedMessage}, attempt {RetryAttempt}. Trying again after {Delay}...", "Failed to retrieve feature manifest", retryAttempt, delay); - delay = retryDelay; retryAttempt++; } } } + async Task FetchToggles(CancellationToken cancellationToken) + { + var toggles = await client.GetLatestManifest(currentContext.ETag, cancellationToken); + + // If the response is null, it means the toggles have not changed since the last request + // of there was an error getting the latest toggles. Either way we want to keep using + // what we already have. + if (toggles is not null) + { + currentContext = new OctopusFeatureContext(toggles, configuration.LoggerFactory); + } + } + public async ValueTask Shutdown() { cancellationTokenSource.Cancel(); From 70be197934284af37611b385c3252e190c62eeb4 Mon Sep 17 00:00:00 2001 From: Dylan Date: Wed, 18 Mar 2026 12:56:13 +1000 Subject: [PATCH 2/2] Fix ETags --- .../OctopusFeatureContextProviderTests.cs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Octopus.OpenFeature.Provider.Tests/OctopusFeatureContextProviderTests.cs b/src/Octopus.OpenFeature.Provider.Tests/OctopusFeatureContextProviderTests.cs index 8184e10..22a487a 100644 --- a/src/Octopus.OpenFeature.Provider.Tests/OctopusFeatureContextProviderTests.cs +++ b/src/Octopus.OpenFeature.Provider.Tests/OctopusFeatureContextProviderTests.cs @@ -46,7 +46,7 @@ public async Task WhenInitialized_ProvidesRetrievedEvaluationContext() { var client = new MockOctopusFeatureClient(new FeatureToggles( [new FeatureToggleEvaluation("Test Feature", "test-feature", true, [])], - new("01-02-03-04"))); + new("\"01-02-03-04\""))); var provider = new OctopusFeatureContextProvider(configuration, client, NullLogger.Instance); await provider.Initialize(); @@ -55,7 +55,7 @@ [new FeatureToggleEvaluation("Test Feature", "test-feature", true, [])], using var scope = new AssertionScope(); context.Should().NotBeNull(); context.ETag.Should().NotBeNull(); - context.ETag!.Tag.Should().Be("01-02-03-04"); + context.ETag!.Tag.Should().Be("\"01-02-03-04\""); context.Evaluate("test-feature", false, context: null).Value.Should().BeTrue(); } @@ -64,7 +64,7 @@ public async Task WhenInitialized_RefreshesCacheAfterCacheDurationExpires() { var client = new MockOctopusFeatureClient(new FeatureToggles( [new FeatureToggleEvaluation("Test Feature", "test-feature", true, [])], - new("01-02-03-04"))); + new("\"01-02-03-04\""))); // Initialize the provider var provider = new OctopusFeatureContextProvider(configuration, client, NullLogger.Instance); @@ -74,13 +74,13 @@ [new FeatureToggleEvaluation("Test Feature", "test-feature", true, [])], using var scope = new AssertionScope(); var context = provider.GetEvaluationContext(); context.ETag.Should().NotBeNull(); - context.ETag!.Tag.Should().Be("01-02-03-04"); + context.ETag!.Tag.Should().Be("\"01-02-03-04\""); context.Evaluate("test-feature", false, context: null).Value.Should().BeTrue(); // Simulate a change in the available feature toggles client.ChangeToggles(new FeatureToggles( [new FeatureToggleEvaluation("Test Feature", "test-feature", false, [])], - new("01-02-03-05"))); + new("\"01-02-03-05\""))); // Wait for the cache to expire await Task.Delay(TimeSpan.FromSeconds(5)); @@ -88,7 +88,7 @@ [new FeatureToggleEvaluation("Test Feature", "test-feature", false, [])], // Validate the updated toggles are available context = provider.GetEvaluationContext(); context.ETag.Should().NotBeNull(); - context.ETag!.Tag.Should().Be("01-02-03-05"); + context.ETag!.Tag.Should().Be("\"01-02-03-05\""); context.Evaluate("test-feature", false, context: null).Value.Should().BeFalse(); }