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 @@
-
+