Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Net.Http.Headers;
using FluentAssertions;
using FluentAssertions.Execution;
using Microsoft.Extensions.Logging;
Expand All @@ -17,12 +18,7 @@ class MockOctopusFeatureClient(FeatureToggles? featureToggles) : IOctopusFeature
{
FeatureToggles? featureToggles = featureToggles;

public Task<bool> HaveFeaturesChanged(byte[] contentHash, CancellationToken cancellationToken)
{
return Task.FromResult(true);
}

public Task<FeatureToggles?> GetFeatureToggleEvaluationManifest(CancellationToken cancellationToken)
public Task<FeatureToggles?> GetLatestManifest(EntityTagHeaderValue? eTag, CancellationToken cancellationToken)
{
return Task.FromResult(featureToggles);
}
Expand All @@ -42,36 +38,33 @@ 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();
var context = provider.GetEvaluationContext();

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);
Expand All @@ -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<bool> HaveFeaturesChanged(byte[] contentHash, CancellationToken cancellationToken)
{
return Task.FromResult(true);
}

public Task<FeatureToggles?> GetFeatureToggleEvaluationManifest(CancellationToken cancellationToken)
public Task<FeatureToggles?> GetLatestManifest(EntityTagHeaderValue? eTag, CancellationToken cancellationToken)
{
throw new Exception("Oops!");
}
Expand All @@ -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");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -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);

Expand All @@ -113,7 +113,7 @@ public void
{
var featureToggles = new FeatureToggles([
new FeatureToggleEvaluation("testfeature", "testfeature", true, [])
], []);
], null);

var context = new OctopusFeatureContext(featureToggles, NullLoggerFactory.Instance);

Expand All @@ -131,7 +131,7 @@ public void WhenAFeatureIsToggledOnForMultipleSegments_EvaluatesCorrectly()
new("region", "au"),
new("region", "us"),
])
], []);
], null);

var context = new OctopusFeatureContext(featureToggles, NullLoggerFactory.Instance);

Expand Down Expand Up @@ -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);

Expand Down
104 changes: 17 additions & 87 deletions src/Octopus.OpenFeature.Provider/OctopusFeatureClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>[] segments)
Expand All @@ -25,64 +25,22 @@ public class FeatureToggleEvaluation(string name, string slug, bool isEnabled, K

interface IOctopusFeatureClient
{
Task<bool> HaveFeaturesChanged(byte[] contentHash, CancellationToken cancellationToken);
Task<FeatureToggles?> GetFeatureToggleEvaluationManifest(CancellationToken cancellationToken);
Task<FeatureToggles?> GetLatestManifest(EntityTagHeaderValue? eTag, CancellationToken cancellationToken);
}

/// <summary>
/// Responsible for retrieving feature toggles from OctoToggle and determining if they have changed.
/// </summary>
class OctopusFeatureClient(OctopusFeatureConfiguration configuration, ILogger logger) : IOctopusFeatureClient
{
public async Task<bool> 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<FeatureCheck>(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;
}

/// <summary>
/// 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.
/// </summary>
public async Task<FeatureToggles?> GetFeatureToggleEvaluationManifest(CancellationToken cancellationToken)
public async Task<FeatureToggles?> GetLatestManifest(EntityTagHeaderValue? eTag, CancellationToken cancellationToken)
{
var client = new HttpClient
{
Expand All @@ -97,31 +55,25 @@ 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 })
{
logger.LogWarning("Failed to retrieve feature toggles for client identifier {ClientIdentifier} from {OctoToggleUrl}", configuration.ClientIdentifier, configuration.ServerUri);
return null;
}

if (!response.Headers.TryGetValues("ContentHash", out IEnumerable<string> 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<FeatureToggleEvaluation[]>(result, JsonSerializerOptions.Web);

Expand All @@ -131,28 +83,6 @@ class FeatureCheck(byte[] contentHash)
return null;
}

var toggles = new FeatureToggles(evaluations, Convert.FromBase64String(rawContentHash));

return toggles;
}

async Task<T?> ExecuteWithRetry<T>(Func<CancellationToken, Task<T>> 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);
}
}
7 changes: 4 additions & 3 deletions src/Octopus.OpenFeature.Provider/OctopusFeatureContext.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<OctopusFeatureContext>();

public static OctopusFeatureContext Empty(ILoggerFactory loggerFactory)
{
return new OctopusFeatureContext(new FeatureToggles([], []), loggerFactory);
return new OctopusFeatureContext(new FeatureToggles([], null), loggerFactory);
}

public ResolutionDetails<bool> Evaluate(string slug, bool defaultValue, EvaluationContext? context)
Expand Down
Loading
Loading