Skip to content

Commit

Permalink
FB-279: Sync FeatureBoard API state back to ExternalState provider if…
Browse files Browse the repository at this point in the history
… registered
  • Loading branch information
ickers committed Jan 11, 2024
1 parent 1b80a4f commit f475ddc
Show file tree
Hide file tree
Showing 6 changed files with 157 additions and 52 deletions.
65 changes: 54 additions & 11 deletions libs/dotnet-sdk-test/FeatureBoardHttpClientTests.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,12 @@
using System;
using System.Linq.Expressions;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json.Nodes;
using Microsoft.Extensions.Logging.Abstractions;
using Bogus;
using Moq;
using FeatureBoard.DotnetSdk.Models;
using System.Net.Http.Headers;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;

namespace FeatureBoard.DotnetSdk.Test
{
Expand Down Expand Up @@ -42,9 +41,13 @@ public async Task ItReturnsCorrectListOfFeatures()
{
// Arrange
IReadOnlyCollection<FeatureConfiguration>? actionArg = null;
void captureArgAction(IReadOnlyCollection<FeatureConfiguration> features) => actionArg = features;
Task captureArgAction(IReadOnlyCollection<FeatureConfiguration> features, CancellationToken token)
{
actionArg = features;
return Task.CompletedTask;
}

var testSubject = new FeatureBoardHttpClient(_mockHttpClient.Object, () => ref _nullETag, captureArgAction, new NullLogger<FeatureBoardHttpClient>());
var testSubject = new FeatureBoardHttpClient(_mockHttpClient.Object, () => ref _nullETag, new FeatureConfigurationUpdated[] { captureArgAction }, new NullLogger<FeatureBoardHttpClient>());

// Act
var result = await testSubject.RefreshFeatureConfiguration(CancellationToken.None);
Expand Down Expand Up @@ -78,15 +81,15 @@ public async Task ItReturnsCorrectListOfFeatures()
public async Task ItDoesNotProcessResponseIfNotModified()
{
// Arrange
static void nopAction(IReadOnlyCollection<FeatureConfiguration> features) { }
static Task nopAction(IReadOnlyCollection<FeatureConfiguration> features, CancellationToken token) => Task.CompletedTask;

Expression<Func<HttpRequestMessage, bool>> hasEtagMatcher = msg => _defaultRequestMatcher.Compile()(msg) && msg.Headers.IfNoneMatch.Any(t => t.Equals(new EntityTagHeaderValue(TestETag)));

_mockHttpClient
.Setup(client => client.SendAsync(It.Is<HttpRequestMessage>(hasEtagMatcher), It.IsAny<CancellationToken>()))
.ReturnsAsync(new HttpResponseMessage() { StatusCode = HttpStatusCode.NotModified });

var testSubject = new FeatureBoardHttpClient(_mockHttpClient.Object, () => ref _nullETag, nopAction, new NullLogger<FeatureBoardHttpClient>());
var testSubject = new FeatureBoardHttpClient(_mockHttpClient.Object, () => ref _nullETag, new FeatureConfigurationUpdated[] { nopAction }, new NullLogger<FeatureBoardHttpClient>());

// Act
var initialResult = await testSubject.RefreshFeatureConfiguration(CancellationToken.None);
Expand All @@ -107,12 +110,10 @@ static void nopAction(IReadOnlyCollection<FeatureConfiguration> features) { }
public async Task ItDoesNotProcessResponseOnNonOkayResponse(HttpStatusCode httpStatusCode)
{
// Arrange
static void exceptionAction(IReadOnlyCollection<FeatureConfiguration> features) => throw new InvalidOperationException();

_mockHttpClient
.Setup(client => client.SendAsync(It.Is<HttpRequestMessage>(_defaultRequestMatcher), It.IsAny<CancellationToken>()))
.ReturnsAsync((HttpRequestMessage request, CancellationToken _) => new HttpResponseMessage(httpStatusCode) { RequestMessage = request });
var testSubject = new FeatureBoardHttpClient(_mockHttpClient.Object, () => ref _nullETag, exceptionAction, new NullLogger<FeatureBoardHttpClient>());
var testSubject = new FeatureBoardHttpClient(_mockHttpClient.Object, () => ref _nullETag, Array.Empty<FeatureConfigurationUpdated>(), new NullLogger<FeatureBoardHttpClient>());

// Act
var result = await testSubject.RefreshFeatureConfiguration(CancellationToken.None);
Expand All @@ -122,6 +123,48 @@ public async Task ItDoesNotProcessResponseOnNonOkayResponse(HttpStatusCode httpS
}


public static object[][] HandlerExceptions => new[]
{
new [] { new ArgumentException() }, // eg. what would happen if duplicate feature keys are returned
};

[Theory]
[MemberData(nameof(HandlerExceptions))]
public async Task ItDoesNotAllowUpdateHandlerExceptionToBubble(Exception exception)
{
// Arrange
static Task nopAction(IReadOnlyCollection<FeatureConfiguration> features, CancellationToken token) => Task.CompletedTask;
Task exceptionAction(IReadOnlyCollection<FeatureConfiguration> features, CancellationToken token) => throw exception;

var testSubject = new FeatureBoardHttpClient(_mockHttpClient.Object, () => ref _nullETag, new FeatureConfigurationUpdated[] { nopAction, exceptionAction }, new NullLogger<FeatureBoardHttpClient>());

// Act
var result = await testSubject.RefreshFeatureConfiguration(CancellationToken.None);

// Assert
Assert.Null(result);
}


[Fact]
public async Task ItDoesNotAllowTransientNetworkRequestErrorsToBubble()
{
// Arrange
_mockHttpClient
.Setup(client => client.SendAsync(It.Is<HttpRequestMessage>(_defaultRequestMatcher), It.IsAny<CancellationToken>()))
.ThrowsAsync(new HttpRequestException());

var testSubject = new FeatureBoardHttpClient(_mockHttpClient.Object, () => ref _nullETag, Array.Empty<FeatureConfigurationUpdated>(), new NullLogger<FeatureBoardHttpClient>());

// Act
var result = await testSubject.RefreshFeatureConfiguration(CancellationToken.None);

// Assert
Assert.Null(result);
}



private static FeatureConfiguration CreateFeature()
{
var faker = new Faker();
Expand Down
38 changes: 35 additions & 3 deletions libs/dotnet-sdk-test/State/FeatureboardStateTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,44 @@ public FeatureBoardStateTests()

}

[Fact]
public async Task StartAsyncLoadsStateFromExternalStateIfProvided()
[Theory]
[InlineData(false)]
[InlineData(null)]
public async Task StartAsyncLoadsStateFromExternalStateIfFeatureBoardServiceDoesNotUpdate(bool? serviceReturn)
{
// Arrange
var featureConfiguration = CreateFeature();

Services.AddServiceMock<IFeatureBoardService>((_, mock) =>
mock.Setup(x => x.RefreshFeatureConfiguration(It.IsAny<CancellationToken>()))
.ReturnsAsync(serviceReturn)
);
Services.AddServiceMock<IFeatureBoardExternalState>((_, mock) =>
mock
.Setup(x => x.GetState(It.IsAny<CancellationToken>()))
.ReturnsAsync(new[] { featureConfiguration })
);

var featureBoardState = Services.BuildServiceProvider().GetRequiredService<FeatureBoardState>();

// Act
await featureBoardState.StartAsync(CancellationToken.None);
var resolvedFeature = featureBoardState.GetSnapshot().Get(featureConfiguration.FeatureKey);

// Assert
resolvedFeature.ShouldBe(featureConfiguration);
}

[Fact(Skip = "Unclear what the behaviour should be in this scenario")]
public async Task StartAsyncLoadsStateFromExternalStateIfFeatureBoardServiceThrowsException()
{
// Arrange
var featureConfiguration = CreateFeature();

Services.AddServiceMock<IFeatureBoardService>((_, mock) =>
mock.Setup(x => x.RefreshFeatureConfiguration(It.IsAny<CancellationToken>()))
.ThrowsAsync(new Exception("Fail!"))
);
Services.AddServiceMock<IFeatureBoardExternalState>((_, mock) =>
mock
.Setup(x => x.GetState(It.IsAny<CancellationToken>()))
Expand All @@ -45,7 +77,7 @@ public async Task StartAsyncLoadsStateFromExternalStateIfProvided()
}

[Fact]
public async Task StartAsyncLoadsStateFromServiceIfExternalStateNotProvided()
public async Task StartAsyncLoadsStateFromServiceEvenIfExternalStateNotProvided()
{
// Arrange
var featureConfiguration = CreateFeature();
Expand Down
1 change: 1 addition & 0 deletions libs/dotnet-sdk/FeatureBoard.DotnetSdk.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="7.*" />
<PackageReference Include="Microsoft.Extensions.Http" Version="7.*" />
<PackageReference Include="Microsoft.Extensions.Http.Polly" Version="7.*" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="7.*" />
<PackageReference Include="Microsoft.Extensions.Options" Version="7.*" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="7.*" />
Expand Down
64 changes: 42 additions & 22 deletions libs/dotnet-sdk/FeatureBoardHttpClient.cs
Original file line number Diff line number Diff line change
@@ -1,27 +1,30 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using Microsoft.Extensions.Logging;
using FeatureBoard.DotnetSdk.Models;
using System.Net.Http.Headers;
using Microsoft.Extensions.Logging;

namespace FeatureBoard.DotnetSdk;

public delegate ref EntityTagHeaderValue? LastETagProvider();

public delegate Task FeatureConfigurationUpdated(IReadOnlyCollection<FeatureConfiguration> configuration, CancellationToken cancellationToken);

internal sealed class FeatureBoardHttpClient : IFeatureBoardHttpClient
{
internal static readonly string Action = "all";

private LastETagProvider _eTag;
private readonly HttpClient _httpClient;
private readonly Action<IReadOnlyCollection<FeatureConfiguration>> _processResult;
private event FeatureConfigurationUpdated OnFeatureConfigurationUpdated = null!;
private readonly ILogger _logger;


public FeatureBoardHttpClient(HttpClient httpClient, LastETagProvider lastModifiedTimeProvider, Action<IReadOnlyCollection<FeatureConfiguration>> processResult, ILogger<FeatureBoardHttpClient> logger)
public FeatureBoardHttpClient(HttpClient httpClient, LastETagProvider lastModifiedTimeProvider, IEnumerable<FeatureConfigurationUpdated> updateHandlers, ILogger<FeatureBoardHttpClient> logger)
{
_httpClient = httpClient;
_processResult = processResult;
foreach (var handler in updateHandlers)
OnFeatureConfigurationUpdated += handler;
_logger = logger;
_eTag = lastModifiedTimeProvider;
}
Expand All @@ -33,32 +36,49 @@ public FeatureBoardHttpClient(HttpClient httpClient, LastETagProvider lastModifi
if (null != eTag)
request.Headers.IfNoneMatch.Add(eTag);

using var response = await _httpClient.SendAsync(request, cancellationToken);

switch (response.StatusCode)
IReadOnlyCollection<FeatureConfiguration>? features = null;
try
{
case HttpStatusCode.NotModified:
_logger.LogDebug("No changes");
return false;
using var response = await _httpClient.SendAsync(request, cancellationToken);

case not HttpStatusCode.OK:
_logger.LogError("Failed to get latest flags: Service returned error {statusCode}({responseBody})", response.StatusCode, await response.Content.ReadAsStringAsync());
return null;
}
switch (response.StatusCode)
{
case HttpStatusCode.NotModified:
_logger.LogDebug("No changes");
return false;

var features = await response.Content.ReadFromJsonAsync<List<FeatureConfiguration>>(cancellationToken: cancellationToken)
?? throw new ApplicationException("Unable to retrieve decode response content");
case not HttpStatusCode.OK:
_logger.LogError("Failed to get latest flags: Service returned error {statusCode}({responseBody})", response.StatusCode, await response.Content.ReadAsStringAsync());
return null;
}

_processResult(features);
updateEtagRef(response.Headers.ETag);
features = await response.Content.ReadFromJsonAsync<List<FeatureConfiguration>>(cancellationToken: cancellationToken)
?? throw new ApplicationException("Unable to retrieve decode response content");

updateEtagRef(response.Headers.ETag);
}
catch (HttpRequestException e)
{
_logger.LogError(e, "Failed to get latest flags");
return null;
}

try
{
await OnFeatureConfigurationUpdated(features, cancellationToken);
return true;
}
catch (ArgumentException e) // eg. thrown due to duplicate feature key
{
_logger.LogError(e, "Failed to update flags");
return null;
}

void updateEtagRef(EntityTagHeaderValue? responseTag) // Sync method to allow use of eTag ref-local variable
{
ref var eTag = ref _eTag();
eTag = responseTag ?? eTag; // if didn't get eTag just retain previous eTag
_logger.LogDebug("Fetching updates done, eTag={eTag}", _eTag);
_logger.LogDebug("Fetching updates done, eTag={eTag}", eTag);
}

return true;
}
}
30 changes: 21 additions & 9 deletions libs/dotnet-sdk/Registration/RegisterFeatureBoard.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Polly;

namespace FeatureBoard.DotnetSdk.Registration;

Expand All @@ -25,17 +26,27 @@ public static FeatureBoardBuilder AddFeatureBoard<TFeatures>(this IServiceCollec
client.BaseAddress = options.Value.HttpEndpoint;
client.DefaultRequestHeaders.Add("x-environment-key", options.Value.EnvironmentApiKey);
// client.Timeout = options.Value.MaxAge - TimeSpan.FromMilliseconds(3); //prevent multiple requests running at the same time.
});
}).AddTransientHttpErrorPolicy(static policyBuilder => // DEBT: Get number retries from config
policyBuilder.WaitAndRetryAsync(retryCount: 5, static retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))) // TODO: Consider adding jitter
);

services.AddSingleton<LastETagProvider>(() => ref lastETag);

services.AddScoped<IFeatureBoardClient<TFeatures>, FeatureBoardClient<TFeatures>>();
services.AddScoped<IFeatureBoardClient>(static c => c.GetRequiredService<IFeatureBoardClient<TFeatures>>());

services.AddSingleton<FeatureBoardState>();
services.AddHostedService(static provider => provider.GetRequiredService<FeatureBoardState>());
services.AddTransient<Action<IReadOnlyCollection<FeatureConfiguration>>>(static provider => provider.GetRequiredService<FeatureBoardState>().Update);
services.AddScoped(static provider => provider.GetRequiredService<FeatureBoardState>().GetSnapshot());
services.AddScoped<IFeatureBoardClient<TFeatures>, FeatureBoardClient<TFeatures>>()
.AddScoped<IFeatureBoardClient>(static c => c.GetRequiredService<IFeatureBoardClient<TFeatures>>());

services.AddSingleton<FeatureBoardState>()
.AddHostedService(static provider => provider.GetRequiredService<FeatureBoardState>())
.AddScoped(static provider => provider.GetRequiredService<FeatureBoardState>().GetSnapshot())
.AddTransient<FeatureConfigurationUpdated>(static provider =>
{
var service = provider.GetRequiredService<FeatureBoardState>();
return (config, _) =>
{
service.Update(config);
return Task.CompletedTask;
};
});

return new FeatureBoardBuilder(services);
}
Expand Down Expand Up @@ -71,7 +82,8 @@ public static FeatureBoardBuilder WithPollingUpdateStrategy(this FeatureBoardBui

public static FeatureBoardBuilder WithExternalState<TStateStore>(this FeatureBoardBuilder builder) where TStateStore : class, IFeatureBoardExternalState
{
builder.Services.AddSingleton<IFeatureBoardExternalState, TStateStore>();
builder.Services.AddSingleton<IFeatureBoardExternalState, TStateStore>()
.AddTransient<FeatureConfigurationUpdated>(static provider => provider.GetRequiredService<FeatureBoard.DotnetSdk.State.IFeatureBoardExternalState>().UpdateState);

return builder;
}
Expand Down
11 changes: 4 additions & 7 deletions libs/dotnet-sdk/State/FeatureBoardState.cs
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using System.Threading;
using FeatureBoard.DotnetSdk.Models;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using FeatureBoard.DotnetSdk.Models;

namespace FeatureBoard.DotnetSdk.State;

Expand All @@ -26,12 +25,10 @@ public FeatureBoardState(IServiceScopeFactory scopeFactory, IFeatureBoardExterna

public async Task StartAsync(CancellationToken cancellationToken)
{
if (_externalState is null)
{
using var scope = _scopeFactory.CreateScope();
await scope.ServiceProvider.GetRequiredService<IFeatureBoardService>().RefreshFeatureConfiguration(cancellationToken);
using var scope = _scopeFactory.CreateScope();
var updated = await scope.ServiceProvider.GetRequiredService<IFeatureBoardService>().RefreshFeatureConfiguration(cancellationToken) ?? false;
if (updated || _externalState is null)
return;
}

var state = await _externalState.GetState(cancellationToken);
if (state == null)
Expand Down

0 comments on commit f475ddc

Please sign in to comment.