diff --git a/CHANGELOG.md b/CHANGELOG.md index 52c4b5b9..dfa2ca0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ All notable changes to the LaunchDarkly .NET SDK will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org). +## [5.6.2] - 2019-03-26 +### Changed: +- The default value for the configuration property `capacity` (maximum number of events that can be stored at once) is now 10000, consistent with the other SDKs, rather than 500. + +### Fixed: +- Under some circumstances, a `CancellationTokenSource` might not be disposed of after making an HTTP request, which could cause a timer object to be leaked. ([#100](https://github.com/launchdarkly/dotnet-client/issues/100)) +- In polling mode, if the client received an HTTP error it would retry the same request one second later. This was inconsistent with the other SDKs; the correct behavior is for it to wait until the next scheduled poll. +- The `HttpClientTimeout` configuration property was being ignored when making HTTP requests to send analytics events. + ## [5.6.1] - 2019-01-14 ### Fixed: - The assemblies in this package now have Authenticode signatures. diff --git a/azure-pipelines.yml b/azure-pipelines.yml new file mode 100644 index 00000000..aa941604 --- /dev/null +++ b/azure-pipelines.yml @@ -0,0 +1,41 @@ +jobs: + - job: netstandard2_0 + pool: + vmImage: 'vs2017-win2016' + steps: + - task: PowerShell@2 + displayName: 'Build' + inputs: + targetType: inline + workingDirectory: $(System.DefaultWorkingDirectory) + script: | + dotnet --version + dotnet restore + dotnet build src/LaunchDarkly.Client -f netstandard2.0 + - task: PowerShell@2 + displayName: 'Test' + inputs: + targetType: inline + workingDirectory: $(System.DefaultWorkingDirectory) + script: | + dotnet test test/LaunchDarkly.Tests/LaunchDarkly.Tests.csproj -f netcoreapp2.0 + - job: net45 + pool: + vmImage: 'vs2017-win2016' + steps: + - task: PowerShell@2 + displayName: 'Build' + inputs: + targetType: inline + workingDirectory: $(System.DefaultWorkingDirectory) + script: | + dotnet --version + dotnet restore + dotnet build src/LaunchDarkly.Client -f net45 + - task: PowerShell@2 + displayName: 'Test' + inputs: + targetType: inline + workingDirectory: $(System.DefaultWorkingDirectory) + script: | + dotnet test test/LaunchDarkly.Tests/LaunchDarkly.Tests.csproj -f net46 diff --git a/src/LaunchDarkly.Client/Configuration.cs b/src/LaunchDarkly.Client/Configuration.cs index f1f2d4d1..18442390 100644 --- a/src/LaunchDarkly.Client/Configuration.cs +++ b/src/LaunchDarkly.Client/Configuration.cs @@ -159,7 +159,7 @@ public class Configuration : IBaseConfiguration /// /// Default value for . /// - private static readonly int DefaultEventQueueCapacity = 500; + private static readonly int DefaultEventQueueCapacity = 10000; /// /// Default value for . /// diff --git a/src/LaunchDarkly.Client/FeatureFlag.cs b/src/LaunchDarkly.Client/FeatureFlag.cs index 1a3cde21..e86e999c 100644 --- a/src/LaunchDarkly.Client/FeatureFlag.cs +++ b/src/LaunchDarkly.Client/FeatureFlag.cs @@ -76,8 +76,17 @@ internal EvalResult(EvaluationDetail result, IList Result = result; PrerequisiteEvents = events; } - } - + } + + int IFlagEventProperties.EventVersion => Version; + + // This method is called by EventFactory to determine if extra tracking should be + // enabled for an event, based on the evaluation reason. It is not enabled yet. + bool IFlagEventProperties.IsExperiment(EvaluationReason reason) + { + return false; + } + internal EvalResult Evaluate(User user, IFeatureStore featureStore, EventFactory eventFactory) { IList prereqEvents = new List(); diff --git a/src/LaunchDarkly.Client/FeatureRequestor.cs b/src/LaunchDarkly.Client/FeatureRequestor.cs index 71183170..cb3a0a98 100644 --- a/src/LaunchDarkly.Client/FeatureRequestor.cs +++ b/src/LaunchDarkly.Client/FeatureRequestor.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Net.Http.Headers; @@ -48,17 +47,12 @@ private void Dispose(bool disposing) // was a problem getting flags. async Task IFeatureRequestor.GetAllDataAsync() { - var cts = new CancellationTokenSource(_config.HttpClientTimeout); - string content = null; - content = await Get(cts, _allUri); - if (string.IsNullOrEmpty(content)) { - return null; + var ret = await GetAsync(_allUri); + if (ret != null) + { + Log.DebugFormat("Get all returned {0} feature flags and {1} segments", + ret.Flags.Keys.Count, ret.Segments.Keys.Count); } - var ret = JsonConvert.DeserializeObject(content); - - Log.DebugFormat("Get all returned {0} feature flags and {1} segments", - ret.Flags.Keys.Count, ret.Segments.Keys.Count); - return ret; } @@ -66,38 +60,46 @@ async Task IFeatureRequestor.GetAllDataAsync() // was a problem getting flags. async Task IFeatureRequestor.GetFlagAsync(string featureKey) { - return await GetObjectAsync(featureKey, "feature flag", typeof(FeatureFlag), _flagsUri); + return await GetAsync(new Uri(_flagsUri, featureKey)); } // Returns the latest version of a segment, or null if it has not been modified. Throws an exception if there // was a problem getting segments. async Task IFeatureRequestor.GetSegmentAsync(string segmentKey) { - return await GetObjectAsync(segmentKey, "segment", typeof(Segment), _segmentsUri); + return await GetAsync(new Uri(_segmentsUri, segmentKey)); } - - internal async Task GetObjectAsync(string key, string objectName, Type objectType, Uri uriBase) where T : class + + private async Task GetAsync(Uri path) where T : class { - var cts = new CancellationTokenSource(_config.HttpClientTimeout); - string content = null; - Uri apiPath = new Uri(uriBase + key); - try + Log.DebugFormat("Getting flags with uri: {0}", path.AbsoluteUri); + var request = new HttpRequestMessage(HttpMethod.Get, path); + if (_etag != null) { - content = await Get(cts, apiPath); - return (content == null) ? null : (T) JsonConvert.DeserializeObject(content, objectType); + request.Headers.IfNoneMatch.Add(_etag); } - catch (Exception e) - { - Log.DebugFormat("Error getting {0}: {1} waiting 1 second before retrying.", - e, objectName, Util.ExceptionMessage(e)); - System.Threading.Tasks.Task.Delay(TimeSpan.FromSeconds(1)).Wait(); - cts = new CancellationTokenSource(_config.HttpClientTimeout); - try - { - content = await Get(cts, apiPath); - return (content == null) ? null : (T)JsonConvert.DeserializeObject(content, objectType); - } + using (var cts = new CancellationTokenSource(_config.HttpClientTimeout)) + { + try + { + using (var response = await _httpClient.SendAsync(request, cts.Token).ConfigureAwait(false)) + { + if (response.StatusCode == HttpStatusCode.NotModified) + { + Log.Debug("Get all flags returned 304: not modified"); + return null; + } + _etag = response.Headers.ETag; + //We ensure the status code after checking for 304, because 304 isn't considered success + if (!response.IsSuccessStatusCode) + { + throw new UnsuccessfulResponseException((int)response.StatusCode); + } + var content = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + return string.IsNullOrEmpty(content) ? null : (T)JsonConvert.DeserializeObject(content); + } + } catch (TaskCanceledException tce) { if (tce.CancellationToken == cts.Token) @@ -106,39 +108,9 @@ internal async Task GetObjectAsync(string key, string objectName, Type obj throw; } //Otherwise this was a request timeout. - throw new TimeoutException("Get item with URL: " + apiPath + + throw new TimeoutException("Get item with URL: " + path.AbsoluteUri + " timed out after : " + _config.HttpClientTimeout); - } - catch (Exception) - { - throw; - } - } - } - - private async Task Get(CancellationTokenSource cts, Uri path) - { - Log.DebugFormat("Getting flags with uri: {0}", path.AbsoluteUri); - var request = new HttpRequestMessage(HttpMethod.Get, path); - if (_etag != null) - { - request.Headers.IfNoneMatch.Add(_etag); - } - - using (var response = await _httpClient.SendAsync(request, cts.Token).ConfigureAwait(false)) - { - if (response.StatusCode == HttpStatusCode.NotModified) - { - Log.Debug("Get all flags returned 304: not modified"); - return null; - } - _etag = response.Headers.ETag; - //We ensure the status code after checking for 304, because 304 isn't considered success - if (!response.IsSuccessStatusCode) - { - throw new UnsuccessfulResponseException((int)response.StatusCode); - } - return await response.Content.ReadAsStringAsync().ConfigureAwait(false); + } } } } diff --git a/src/LaunchDarkly.Client/LaunchDarkly.Client.csproj b/src/LaunchDarkly.Client/LaunchDarkly.Client.csproj index 019ebae5..d3a414c8 100644 --- a/src/LaunchDarkly.Client/LaunchDarkly.Client.csproj +++ b/src/LaunchDarkly.Client/LaunchDarkly.Client.csproj @@ -1,6 +1,6 @@  - 5.6.1 + 5.6.2 netstandard1.4;netstandard1.6;netstandard2.0;net45 https://raw.githubusercontent.com/launchdarkly/.net-client/master/LICENSE portable @@ -15,10 +15,10 @@ false - + - + diff --git a/test/LaunchDarkly.Tests/FeatureRequestorTest.cs b/test/LaunchDarkly.Tests/FeatureRequestorTest.cs new file mode 100644 index 00000000..c6335dfc --- /dev/null +++ b/test/LaunchDarkly.Tests/FeatureRequestorTest.cs @@ -0,0 +1,142 @@ +using LaunchDarkly.Client; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using WireMock; +using WireMock.Logging; +using WireMock.RequestBuilders; +using WireMock.ResponseBuilders; +using WireMock.Server; +using Xunit; + +namespace LaunchDarkly.Tests +{ + public class FeatureRequestorTest : IDisposable + { + private const string AllDataJson = @"{""flags"":{""flag1"":{""key"":""flag1"",""version"":1}},""segments"":{""seg1"":{""key"":""seg1"",""version"":2}}}"; + + private FluentMockServer _server; + private IFeatureRequestor _requestor; + + public FeatureRequestorTest() + { + _server = FluentMockServer.Start(); + var config = Configuration.Default("key").WithUri(_server.Urls[0]).WithHttpClientTimeout(TimeSpan.FromDays(1)); + _requestor = new FeatureRequestor(config); + } + + void IDisposable.Dispose() + { + _server.Stop(); + } + + [Fact] + public async Task GetAllUsesCorrectUriAndParsesResponseAsync() + { + _server.Given(Request.Create().UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody(AllDataJson)); + var result = await _requestor.GetAllDataAsync(); + + var req = GetLastRequest(); + Assert.Equal("/sdk/latest-all", req.Path); + + Assert.Equal(1, result.Flags.Count); + Assert.Equal(1, result.Flags["flag1"].Version); + Assert.Equal(1, result.Segments.Count); + Assert.Equal(2, result.Segments["seg1"].Version); + } + + [Fact] + public async Task GetAllStoresAndSendsEtag() + { + var etag = @"""abc123"""; // note that etag strings must be quoted + _server.Given(Request.Create().UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithHeader("Etag", etag).WithBody(AllDataJson)); + await _requestor.GetAllDataAsync(); + await _requestor.GetAllDataAsync(); + + var reqs = new List(_server.LogEntries); + Assert.Equal(2, reqs.Count); + Assert.False(reqs[0].RequestMessage.Headers.ContainsKey("If-None-Match")); + Assert.Equal(new List { etag }, reqs[1].RequestMessage.Headers["If-None-Match"]); + } + + [Fact] + public async Task GetAllReturnsNullIfNotModified() + { + var etag = @"""abc123"""; // note that etag strings must be quoted + + _server.Given(Request.Create().UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithHeader("Etag", etag).WithBody(AllDataJson)); + var result1 = await _requestor.GetAllDataAsync(); + + _server.Reset(); + _server.Given(Request.Create().UsingGet().WithHeader("If-None-Match", etag)) + .RespondWith(Response.Create().WithStatusCode(304)); + var result2 = await _requestor.GetAllDataAsync(); + + Assert.NotNull(result1); + Assert.Null(result2); + } + + [Fact] + public async Task GetAllDoesNotRetryFailedRequest() + { + _server.Given(Request.Create().UsingGet()) + .RespondWith(Response.Create().WithStatusCode(503)); + try + { + await _requestor.GetAllDataAsync(); + } + catch (UnsuccessfulResponseException e) + { + Assert.Equal(503, e.StatusCode); + } + + var reqs = new List(_server.LogEntries); + Assert.Equal(1, reqs.Count); + } + + [Fact] + public async Task GetFlagUsesCorrectUriAndParsesResponseAsync() + { + var json = @"{""key"":""flag1"",""version"":1}"; + _server.Given(Request.Create().UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody(json)); + var flag = await _requestor.GetFlagAsync("flag1"); + + var req = GetLastRequest(); + Assert.Equal("/sdk/latest-flags/flag1", req.Path); + + Assert.NotNull(flag); + Assert.Equal("flag1", flag.Key); + Assert.Equal(1, flag.Version); + } + + [Fact] + public async Task GetSegmentUsesCorrectUriAndParsesResponseAsync() + { + var json = @"{""key"":""seg1"",""version"":2}"; + _server.Given(Request.Create().UsingGet()) + .RespondWith(Response.Create().WithStatusCode(200).WithBody(json)); + var segment = await _requestor.GetSegmentAsync("seg1"); + + var req = GetLastRequest(); + Assert.Equal("/sdk/latest-segments/seg1", req.Path); + + Assert.NotNull(segment); + Assert.Equal("seg1", segment.Key); + Assert.Equal(2, segment.Version); + } + + private RequestMessage GetLastRequest() + { + foreach (LogEntry le in _server.LogEntries) + { + return le.RequestMessage; + } + Assert.True(false, "Did not receive a request"); + return null; + } + } +} diff --git a/test/LaunchDarkly.Tests/LaunchDarkly.Tests.csproj b/test/LaunchDarkly.Tests/LaunchDarkly.Tests.csproj index 2fa30739..732bc8fc 100644 --- a/test/LaunchDarkly.Tests/LaunchDarkly.Tests.csproj +++ b/test/LaunchDarkly.Tests/LaunchDarkly.Tests.csproj @@ -1,9 +1,10 @@  - netcoreapp1.1;netcoreapp2.0 + netcoreapp1.1;netcoreapp2.0;net46 LaunchDarkly.Tests LaunchDarkly.Tests false + true @@ -11,7 +12,7 @@ - +