diff --git a/USE_CASES.md b/USE_CASES.md
index 7dd98b924..b86e7cc93 100644
--- a/USE_CASES.md
+++ b/USE_CASES.md
@@ -8,6 +8,7 @@ This documentation provides examples for specific use cases. Please [open an iss
* [Email - Send a Single Email to a Single Recipient](#singleemailsinglerecipient)
* [Email - Send Multiple Emails to Multiple Recipients](#multipleemailsmultiplerecipients)
* [Email - Transactional Templates](#transactional_templates)
+* [Transient Fault Handling](#transient_faults)
# Attachments
@@ -582,3 +583,61 @@ namespace Example
}
}
```
+
+
+# Transient Fault Handling
+
+The SendGridClient provides functionality for handling transient errors that might occur when sending an HttpRequest. This includes client side timeouts while sending the mail, or certain errors returned within the 500 range. Errors within the 500 range are limited to 500 Internal Server Error, 502 Bad Gateway, 503 Service unavailable and 504 Gateway timeout.
+
+By default, retry behaviour is off, you must explicitly enable it by setting the retry count to a value greater than zero. To set the retry count, you must use the SendGridClient construct that takes a **SendGridClientOptions** object, allowing you to configure the **ReliabilitySettings**
+
+### RetryCount
+
+The amount of times to retry the operation before reporting an exception to the caller. This is in addition to the initial attempt so setting a value of 1 would result in 2 attempts, the initial attempt and the retry. Defaults to zero, retry behaviour is not enabled. The maximum amount of retries permitted is 5.
+
+### MinimumBackOff
+
+The minimum amount of time to wait between retries.
+
+### MaximumBackOff
+
+The maximum possible amount of time to wait between retries. The maximum value allowed is 30 seconds
+
+### DeltaBackOff
+
+The value that will be used to calculate a random delta in the exponential delay between retries. A random element of time is factored into the delta calculation as this helps avoid many clients retrying at regular intervals.
+
+
+## Examples
+
+In this example we are setting RetryCount to 2, with a mimimum wait time of 1 seconds, a maximum of 10 seconds and a delta of 3 seconds
+
+```csharp
+
+var options = new SendGridClientOptions
+{
+ ApiKey = "Your-Api-Key",
+ ReliabilitySettings = new ReliabilitySettings(2, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(3))
+};
+
+var client = new SendGridClient(options);
+
+```
+
+The SendGridClientOptions object defines all the settings that can be set for the client, e.g.
+
+```csharp
+
+var options = new SendGridClientOptions
+{
+ ApiKey = "Your-Api-Key",
+ ReliabilitySettings = new ReliabilitySettings(2, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(3)),
+ Host = "Your-Host",
+ UrlPath = "Url-Path",
+ Version = "3",
+ RequestHeaders = new Dictionary() {{"header-key", "header-value"}}
+};
+
+var client = new SendGridClient(options);
+
+```
diff --git a/src/SendGrid/Reliability/ReliabilitySettings.cs b/src/SendGrid/Reliability/ReliabilitySettings.cs
new file mode 100644
index 000000000..d3316f936
--- /dev/null
+++ b/src/SendGrid/Reliability/ReliabilitySettings.cs
@@ -0,0 +1,88 @@
+namespace SendGrid.Helpers.Reliability
+{
+ using System;
+
+ ///
+ /// Defines the reliability settings to use on HTTP requests
+ ///
+ public class ReliabilitySettings
+ {
+ ///
+ /// Initializes a new instance of the class with default settings.
+ ///
+ public ReliabilitySettings()
+ : this(0, TimeSpan.Zero, TimeSpan.Zero, TimeSpan.Zero)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The maximum number of retries to execute against when sending an HTTP Request before throwing an exception
+ /// The minimum amount of time to wait between between HTTP retries
+ /// the maximum amount of time to wait between between HTTP retries
+ /// the value that will be used to calculate a random delta in the exponential delay between retries
+ public ReliabilitySettings(int maximumNumberOfRetries, TimeSpan minimumBackoff, TimeSpan maximumBackOff, TimeSpan deltaBackOff)
+ {
+ if (maximumNumberOfRetries < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maximumNumberOfRetries), "maximumNumberOfRetries must be greater than 0");
+ }
+
+ if (maximumNumberOfRetries > 5)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maximumNumberOfRetries), "The maximum number of retries allowed is 5");
+ }
+
+ if (minimumBackoff.Ticks < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(minimumBackoff), "minimumBackoff must be greater than 0");
+ }
+
+ if (maximumBackOff.Ticks < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maximumBackOff), "maximumBackOff must be greater than 0");
+ }
+
+ if (maximumBackOff.TotalSeconds > 30)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maximumBackOff), "maximumBackOff must be less than 30 seconds");
+ }
+
+ if (deltaBackOff.Ticks < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(deltaBackOff), "deltaBackOff must be greater than 0");
+ }
+
+ if (minimumBackoff.TotalMilliseconds > maximumBackOff.TotalMilliseconds)
+ {
+ throw new ArgumentOutOfRangeException(nameof(minimumBackoff), "minimumBackoff must be less than maximumBackOff");
+ }
+
+ this.MaximumNumberOfRetries = maximumNumberOfRetries;
+ this.MinimumBackOff = minimumBackoff;
+ this.DeltaBackOff = deltaBackOff;
+ this.MaximumBackOff = maximumBackOff;
+ }
+
+ ///
+ /// Gets the maximum number of retries to execute against when sending an HTTP Request before throwing an exception. Defaults to 0 (no retries, you must explicitly enable)
+ ///
+ public int MaximumNumberOfRetries { get; }
+
+ ///
+ /// Gets the minimum amount of time to wait between between HTTP retries. Defaults to 1 second
+ ///
+ public TimeSpan MinimumBackOff { get; }
+
+ ///
+ /// Gets the maximum amount of time to wait between between HTTP retries. Defaults to 10 seconds
+ ///
+ public TimeSpan MaximumBackOff { get; }
+
+ ///
+ /// Gets the value that will be used to calculate a random delta in the exponential delay between retries. Defaults to 1 second
+ ///
+ public TimeSpan DeltaBackOff { get; }
+ }
+}
diff --git a/src/SendGrid/Reliability/RetryDelegatingHandler.cs b/src/SendGrid/Reliability/RetryDelegatingHandler.cs
new file mode 100644
index 000000000..222701dfa
--- /dev/null
+++ b/src/SendGrid/Reliability/RetryDelegatingHandler.cs
@@ -0,0 +1,120 @@
+namespace SendGrid.Helpers.Reliability
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Net;
+ using System.Net.Http;
+ using System.Threading;
+ using System.Threading.Tasks;
+
+ ///
+ /// A delegating handler that provides retry functionality while executing a request
+ ///
+ public class RetryDelegatingHandler : DelegatingHandler
+ {
+ private static readonly List RetriableServerErrorStatusCodes =
+ new List()
+ {
+ HttpStatusCode.InternalServerError,
+ HttpStatusCode.BadGateway,
+ HttpStatusCode.ServiceUnavailable,
+ HttpStatusCode.GatewayTimeout
+ };
+
+ private readonly ReliabilitySettings settings;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// A ReliabilitySettings instance
+ public RetryDelegatingHandler(ReliabilitySettings settings)
+ : this(new HttpClientHandler(), settings)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// A HttpMessageHandler instance to set as the innner handler
+ /// A ReliabilitySettings instance
+ public RetryDelegatingHandler(HttpMessageHandler innerHandler, ReliabilitySettings settings)
+ : base(innerHandler)
+ {
+ this.settings = settings;
+ }
+
+ protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ if (this.settings.MaximumNumberOfRetries == 0)
+ {
+ return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
+ }
+
+ HttpResponseMessage responseMessage = null;
+
+ var numberOfAttempts = 0;
+ var sent = false;
+
+ while (!sent)
+ {
+ var waitFor = this.GetNextWaitInterval(numberOfAttempts);
+
+ try
+ {
+ responseMessage = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
+
+ ThrowHttpRequestExceptionIfResponseCodeCanBeRetried(responseMessage);
+
+ sent = true;
+ }
+ catch (TaskCanceledException)
+ {
+ numberOfAttempts++;
+
+ if (numberOfAttempts > this.settings.MaximumNumberOfRetries)
+ {
+ throw new TimeoutException();
+ }
+
+ // ReSharper disable once MethodSupportsCancellation, cancel will be indicated on the token
+ await Task.Delay(waitFor).ConfigureAwait(false);
+ }
+ catch (HttpRequestException)
+ {
+ numberOfAttempts++;
+
+ if (numberOfAttempts > this.settings.MaximumNumberOfRetries)
+ {
+ throw;
+ }
+
+ await Task.Delay(waitFor).ConfigureAwait(false);
+ }
+ }
+
+ return responseMessage;
+ }
+
+ private static void ThrowHttpRequestExceptionIfResponseCodeCanBeRetried(HttpResponseMessage responseMessage)
+ {
+ if (RetriableServerErrorStatusCodes.Contains(responseMessage.StatusCode))
+ {
+ throw new HttpRequestException(string.Format("Http status code '{0}' indicates server error", responseMessage.StatusCode));
+ }
+ }
+
+ private TimeSpan GetNextWaitInterval(int numberOfAttempts)
+ {
+ var random = new Random();
+
+ var delta = (int)((Math.Pow(2.0, numberOfAttempts) - 1.0) *
+ random.Next(
+ (int)(this.settings.DeltaBackOff.TotalMilliseconds * 0.8),
+ (int)(this.settings.DeltaBackOff.TotalMilliseconds * 1.2)));
+
+ var interval = (int)Math.Min(this.settings.MinimumBackOff.TotalMilliseconds + delta, this.settings.MaximumBackOff.TotalMilliseconds);
+
+ return TimeSpan.FromMilliseconds(interval);
+ }
+ }
+}
diff --git a/src/SendGrid/SendGridClient.cs b/src/SendGrid/SendGridClient.cs
index 6197967ce..b5c5a7fd9 100644
--- a/src/SendGrid/SendGridClient.cs
+++ b/src/SendGrid/SendGridClient.cs
@@ -16,12 +16,15 @@ namespace SendGrid
using System.Text;
using System.Threading;
using System.Threading.Tasks;
+ using SendGrid.Helpers.Reliability;
///
/// A HTTP client wrapper for interacting with SendGrid's API
///
public class SendGridClient : ISendGridClient
{
+ private readonly SendGridClientOptions options = new SendGridClientOptions();
+
///
/// Gets or sets the path to the API resource.
///
@@ -63,16 +66,48 @@ public SendGridClient(IWebProxy webProxy, string apiKey, string host = null, Dic
PreAuthenticate = true,
UseDefaultCredentials = false,
};
- client = new HttpClient(httpClientHandler);
+
+ var retryHandler = new RetryDelegatingHandler(httpClientHandler, options.ReliabilitySettings);
+
+ client = new HttpClient(retryHandler);
}
else
{
- client = new HttpClient();
+ client = CreateHttpClientWithRetryHandler();
}
InitiateClient(apiKey, host, requestHeaders, version, urlPath);
}
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// A instance that defines the configuration settings to use with the client
+ /// Interface to the SendGrid REST API
+ public SendGridClient(SendGridClientOptions options)
+ : this(null, options)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// An optional http client which may me injected in order to facilitate testing.
+ /// A instance that defines the configuration settings to use with the client
+ /// Interface to the SendGrid REST API
+ internal SendGridClient(HttpClient httpClient, SendGridClientOptions options)
+ {
+ if (options == null)
+ {
+ throw new ArgumentNullException(nameof(options));
+ }
+
+ this.options = options;
+ client = (httpClient == null) ? CreateHttpClientWithRetryHandler() : httpClient;
+
+ InitiateClient(options.ApiKey, options.Host, options.RequestHeaders, options.Version, options.UrlPath);
+ }
+
///
/// Initializes a new instance of the class.
///
@@ -84,10 +119,8 @@ public SendGridClient(IWebProxy webProxy, string apiKey, string host = null, Dic
/// Path to endpoint (e.g. /path/to/endpoint)
/// Interface to the SendGrid REST API
public SendGridClient(HttpClient httpClient, string apiKey, string host = null, Dictionary requestHeaders = null, string version = "v3", string urlPath = null)
+ : this(httpClient, new SendGridClientOptions() { ApiKey = apiKey, Host = host, RequestHeaders = requestHeaders, Version = version, UrlPath = urlPath })
{
- client = (httpClient == null) ? new HttpClient() : httpClient;
-
- InitiateClient(apiKey, host, requestHeaders, version, urlPath);
}
///
@@ -159,6 +192,11 @@ private void InitiateClient(string apiKey, string host, Dictionary
/// The supported API methods.
///
@@ -227,11 +265,11 @@ public virtual AuthenticationHeaderValue AddAuthorization(KeyValuePair
public async Task RequestAsync(
- SendGridClient.Method method,
- string requestBody = null,
- string queryParams = null,
- string urlPath = null,
- CancellationToken cancellationToken = default(CancellationToken))
+ SendGridClient.Method method,
+ string requestBody = null,
+ string queryParams = null,
+ string urlPath = null,
+ CancellationToken cancellationToken = default(CancellationToken))
{
var endpoint = client.BaseAddress + BuildUrl(urlPath, queryParams);
@@ -261,10 +299,10 @@ public async Task RequestAsync(
public async Task SendEmailAsync(SendGridMessage msg, CancellationToken cancellationToken = default(CancellationToken))
{
return await RequestAsync(
- Method.POST,
- msg.Serialize(),
- urlPath: "mail/send",
- cancellationToken: cancellationToken).ConfigureAwait(false);
+ Method.POST,
+ msg.Serialize(),
+ urlPath: "mail/send",
+ cancellationToken: cancellationToken).ConfigureAwait(false);
}
///
diff --git a/src/SendGrid/SendGridClientOptions.cs b/src/SendGrid/SendGridClientOptions.cs
new file mode 100644
index 000000000..1d830c700
--- /dev/null
+++ b/src/SendGrid/SendGridClientOptions.cs
@@ -0,0 +1,59 @@
+namespace SendGrid
+{
+ using System;
+ using System.Collections.Generic;
+ using SendGrid.Helpers.Reliability;
+
+ ///
+ /// Defines the options to use with the SendGrid client
+ ///
+ public class SendGridClientOptions
+ {
+ private ReliabilitySettings reliabilitySettings = new ReliabilitySettings();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public SendGridClientOptions()
+ {
+ RequestHeaders = new Dictionary();
+ Host = "https://api.sendgrid.com";
+ Version = "v3";
+ }
+
+ ///
+ /// Gets or sets the reliability settings to use on HTTP Requests
+ ///
+ public ReliabilitySettings ReliabilitySettings
+ {
+ get => this.reliabilitySettings;
+
+ set => this.reliabilitySettings = value ?? throw new ArgumentNullException(nameof(value));
+ }
+
+ ///
+ /// Gets or sets the SendGrid API key
+ ///
+ public string ApiKey { get; set; }
+
+ ///
+ /// Gets or sets the request headers to use on HttpRequests sent to SendGrid
+ ///
+ public Dictionary RequestHeaders { get; set; }
+
+ ///
+ /// Gets or sets base url (e.g. https://api.sendgrid.com, this is the default)
+ ///
+ public string Host { get; set; }
+
+ ///
+ /// Gets or sets API version, override AddVersion to customize
+ ///
+ public string Version { get; set; }
+
+ ///
+ /// Gets or sets the path to the API endpoint.
+ ///
+ public string UrlPath { get; set; }
+ }
+}
diff --git a/tests/SendGrid.Tests/Integration.cs b/tests/SendGrid.Tests/Integration.cs
index 5ec2cbe65..0791ed31d 100644
--- a/tests/SendGrid.Tests/Integration.cs
+++ b/tests/SendGrid.Tests/Integration.cs
@@ -11,6 +11,8 @@
using Xunit;
using System.Threading;
using System.Text;
+ using Helpers.Reliability;
+ using Reliability;
using Xunit.Abstractions;
public class IntegrationFixture : IDisposable
@@ -43,7 +45,7 @@ public void Dispose()
{
if (Environment.GetEnvironmentVariable("TRAVIS") != "true")
{
- process.Kill();
+ process.Kill();
Trace.WriteLine("Shutting Down Prism");
}
}
@@ -5992,6 +5994,26 @@ public async Task TestTakesHttpClientFactoryAsConstructorArgumentAndUsesItInHttp
Assert.Equal(httpStatusCode, response.StatusCode);
Assert.Equal(httpResponse, response.Body.ReadAsStringAsync().Result);
}
+
+ [Fact]
+ public void TestTakesProxyAsConstructorArgumentAndInitiailsesHttpClient()
+ {
+ var urlPath = "urlPath";
+
+ var sg = new SendGridClient(new FakeWebProxy(), fixture.apiKey, urlPath: "urlPath");
+
+ Assert.Equal(sg.UrlPath, urlPath);
+ }
+
+ [Fact]
+ public void TestTakesNullProxyAsConstructorArgumentAndInitiailsesHttpClient()
+ {
+ var urlPath = "urlPath";
+
+ var sg = new SendGridClient(null as IWebProxy, fixture.apiKey, urlPath: "urlPath");
+
+ Assert.Equal(sg.UrlPath, urlPath);
+ }
///
/// Tests the conditions in issue #358.
@@ -6106,6 +6128,80 @@ public void TestJsonNetReferenceHandling(string referenceHandlingProperty)
bool containsReferenceHandlingProperty = serializedMessage.Contains(referenceHandlingProperty);
Assert.False(containsReferenceHandlingProperty);
}
+
+ [Fact]
+ public async Task TestRetryBehaviourThrowsTimeoutException()
+ {
+ var msg = new SendGridMessage();
+ msg.SetFrom(new EmailAddress("test@example.com"));
+ msg.AddTo(new EmailAddress("test@example.com"));
+ msg.SetSubject("Hello World from the SendGrid CSharp Library");
+ msg.AddContent(MimeType.Html, "HTML content");
+
+ var options = new SendGridClientOptions
+ {
+ ApiKey = fixture.apiKey,
+ ReliabilitySettings = new ReliabilitySettings(1, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(1)),
+ Host = "http://localhost:4010"
+ };
+
+ var id = "test_url_param";
+
+ var retryHandler = new RetryDelegatingHandler(new HttpClientHandler(), options.ReliabilitySettings);
+
+ HttpClient clientToInject = new HttpClient(retryHandler) { Timeout = TimeSpan.FromMilliseconds(1) };
+ var sg = new SendGridClient(clientToInject, options.ApiKey, options.Host);
+
+ var exception = await Assert.ThrowsAsync(() => sg.SendEmailAsync(msg));
+
+ Assert.NotNull(exception);
+ }
+
+ [Fact]
+ public async Task TestRetryBehaviourSucceedsOnSecondAttempt()
+ {
+ var msg = new SendGridMessage();
+ msg.SetFrom(new EmailAddress("test@example.com"));
+ msg.AddTo(new EmailAddress("test@example.com"));
+ msg.SetSubject("Hello World from the SendGrid CSharp Library");
+ msg.AddContent(MimeType.Html, "HTML content");
+
+ var options = new SendGridClientOptions
+ {
+ ApiKey = fixture.apiKey,
+ ReliabilitySettings = new ReliabilitySettings(1, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(1))
+ };
+
+ var id = "test_url_param";
+
+ var httpMessageHandler = new RetryTestBehaviourDelegatingHandler();
+ httpMessageHandler.AddBehaviour(httpMessageHandler.TaskCancelled);
+ httpMessageHandler.AddBehaviour(httpMessageHandler.OK);
+
+ var retryHandler = new RetryDelegatingHandler(httpMessageHandler, options.ReliabilitySettings);
+
+ HttpClient clientToInject = new HttpClient(retryHandler);
+ var sg = new SendGridClient(clientToInject, options.ApiKey, options.Host);
+
+ var result = await sg.SendEmailAsync(msg);
+
+ Assert.Equal(HttpStatusCode.OK, result.StatusCode);
+ }
+ }
+
+ public class FakeWebProxy : IWebProxy
+ {
+ public Uri GetProxy(Uri destination)
+ {
+ return new Uri("https://dummy-proxy");
+ }
+
+ public bool IsBypassed(Uri host)
+ {
+ return false;
+ }
+
+ public ICredentials Credentials { get; set; }
}
public class FakeHttpMessageHandler : HttpMessageHandler
diff --git a/tests/SendGrid.Tests/Reliability/ReliabilitySettingsTests.cs b/tests/SendGrid.Tests/Reliability/ReliabilitySettingsTests.cs
new file mode 100644
index 000000000..dad52ce78
--- /dev/null
+++ b/tests/SendGrid.Tests/Reliability/ReliabilitySettingsTests.cs
@@ -0,0 +1,114 @@
+namespace SendGrid.Tests.Reliability
+{
+ using System;
+
+ using Helpers.Reliability;
+ using Xunit;
+
+ public class ReliabilitySettingsTests
+ {
+ [Fact]
+ public void ShouldNotAllowNegativeRetryCount()
+ {
+ var exception = Assert.Throws(() =>
+ new ReliabilitySettings(-1,
+ TimeSpan.FromSeconds(1),
+ TimeSpan.FromSeconds(1),
+ TimeSpan.FromSeconds(1)));
+
+ Assert.Contains("maximumNumberOfRetries must be greater than 0", exception.Message);
+ }
+
+ [Fact]
+ public void ShouldNotAllowNegativeMinimumBackoffTime()
+ {
+ var exception = Assert.Throws(() =>
+ new ReliabilitySettings(1,
+ TimeSpan.FromSeconds(-1),
+ TimeSpan.FromSeconds(1),
+ TimeSpan.FromSeconds(1)));
+
+ Assert.Contains("minimumBackoff must be greater than 0", exception.Message);
+ }
+
+ [Fact]
+ public void ShouldNotAllowNegativeMaximumBackoffTime()
+ {
+ var exception = Assert.Throws(() =>
+ new ReliabilitySettings(1,
+ TimeSpan.FromSeconds(1),
+ TimeSpan.FromSeconds(-11),
+ TimeSpan.FromSeconds(1)));
+
+ Assert.Contains("maximumBackOff must be greater than 0", exception.Message);
+ }
+
+ [Fact]
+ public void ShouldNotAllowNegativeDeltaBackoffTime()
+ {
+ var exception = Assert.Throws(() =>
+ new ReliabilitySettings(1,
+ TimeSpan.FromSeconds(1),
+ TimeSpan.FromSeconds(1),
+ TimeSpan.FromSeconds(-1)));
+
+ Assert.Contains("deltaBackOff must be greater than 0", exception.Message);
+ }
+
+ [Fact]
+ public void ShouldNotAllowRetryCountGreaterThan5()
+ {
+ var exception = Assert.Throws(() =>
+ new ReliabilitySettings(6,
+ TimeSpan.FromSeconds(1),
+ TimeSpan.FromSeconds(1),
+ TimeSpan.FromSeconds(1)));
+
+ Assert.Contains("The maximum number of retries allowed is 5", exception.Message);
+ }
+
+ [Fact]
+ public void ShouldNotAllowMinimumBackOffGreaterThanMaximumBackoff()
+ {
+ var exception = Assert.Throws(() =>
+ new ReliabilitySettings(1,
+ TimeSpan.FromSeconds(11),
+ TimeSpan.FromSeconds(10),
+ TimeSpan.FromSeconds(1)));
+
+ Assert.Contains("minimumBackoff must be less than maximumBackOff", exception.Message);
+ }
+
+ [Fact]
+ public void ShouldNotAllowMaximumBackOffGreaterThan30Seconds()
+ {
+ var exception = Assert.Throws(() =>
+ new ReliabilitySettings(1,
+ TimeSpan.FromSeconds(1),
+ TimeSpan.FromSeconds(31),
+ TimeSpan.FromSeconds(1)));
+
+ Assert.Contains("maximumBackOff must be less than 30 seconds", exception.Message);
+ }
+
+ [Fact]
+ public void ShouldPassValidValuesFromDefaultConstruct()
+ {
+ var defaultSettings = new ReliabilitySettings();
+
+ Assert.Equal(TimeSpan.Zero, defaultSettings.MaximumBackOff);
+ Assert.Equal(TimeSpan.Zero, defaultSettings.MinimumBackOff);
+ Assert.Equal(TimeSpan.Zero, defaultSettings.DeltaBackOff);
+ Assert.Equal(0, defaultSettings.MaximumNumberOfRetries);
+ }
+
+ [Fact]
+ public void ShouldNotAllowNullInstanceOnSendGridClientOptions()
+ {
+ var options = new SendGridClientOptions();
+
+ Assert.Throws(() => options.ReliabilitySettings = null);
+ }
+ }
+}
+
diff --git a/tests/SendGrid.Tests/Reliability/RetryDelegatingHandlerTests.cs b/tests/SendGrid.Tests/Reliability/RetryDelegatingHandlerTests.cs
new file mode 100644
index 000000000..f8d026427
--- /dev/null
+++ b/tests/SendGrid.Tests/Reliability/RetryDelegatingHandlerTests.cs
@@ -0,0 +1,116 @@
+namespace SendGrid.Tests.Reliability
+{
+ using System;
+ using System.Net;
+ using System.Net.Http;
+ using System.Threading.Tasks;
+ using SendGrid.Helpers.Reliability;
+ using Xunit;
+
+ public class RetryDelegatingHandlerTests
+ {
+ private readonly HttpClient client;
+
+ private readonly RetryTestBehaviourDelegatingHandler innerHandler;
+
+ public RetryDelegatingHandlerTests()
+ {
+ var reliabilitySettings = new ReliabilitySettings(1, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(10),
+ TimeSpan.FromSeconds(1));
+
+ innerHandler = new RetryTestBehaviourDelegatingHandler();
+ client = new HttpClient(new RetryDelegatingHandler(innerHandler, reliabilitySettings))
+ {
+ BaseAddress = new Uri("http://localhost")
+ };
+ }
+
+ [Fact]
+ public async Task ShouldReturnHttpResponseAndNotRetryWhenSuccessful()
+ {
+ innerHandler.AddBehaviour(innerHandler.OK);
+
+ var result = await client.SendAsync(new HttpRequestMessage());
+
+ Assert.Equal(result.StatusCode, HttpStatusCode.OK);
+ Assert.Equal(1, innerHandler.InvocationCount);
+ }
+
+ [Fact]
+ public async Task ShouldReturnHttpResponseAndNotRetryWhenUnauthorised()
+ {
+ innerHandler.AddBehaviour(innerHandler.AuthenticationError);
+
+ var result = await client.SendAsync(new HttpRequestMessage());
+
+ Assert.Equal(result.StatusCode, HttpStatusCode.Unauthorized);
+ Assert.Equal(1, innerHandler.InvocationCount);
+ }
+
+ [Fact]
+ public async Task ShouldReturnErrorWithoutRetryWhenErrorIsNotTransient()
+ {
+ innerHandler.AddBehaviour(innerHandler.NonTransientException);
+
+ await Assert.ThrowsAsync(() => client.SendAsync(new HttpRequestMessage()));
+
+ Assert.Equal(1, innerHandler.InvocationCount);
+ }
+
+ [Fact]
+ public async Task ShouldReturnErrorWithoutRetryWhen500ErrorStatusIsNotTransient()
+ {
+ innerHandler.AddBehaviour(innerHandler.HttpVersionNotSupported);
+
+ var response = await client.SendAsync(new HttpRequestMessage());
+
+ Assert.Equal(HttpStatusCode.HttpVersionNotSupported, response.StatusCode);
+ Assert.Equal(1, innerHandler.InvocationCount);
+ }
+
+ [Fact]
+ public async Task ShouldReturnErrorWithoutRetryWhen501ErrorStatus()
+ {
+ innerHandler.AddBehaviour(innerHandler.NotImplemented);
+
+ var response = await client.SendAsync(new HttpRequestMessage());
+
+ Assert.Equal(HttpStatusCode.NotImplemented, response.StatusCode);
+ Assert.Equal(1, innerHandler.InvocationCount);
+ }
+
+ [Fact]
+ public async Task ShouldRetryOnceWhenFailedOnFirstAttemptThenSuccessful()
+ {
+ innerHandler.AddBehaviour(innerHandler.TaskCancelled);
+ innerHandler.AddBehaviour(innerHandler.OK);
+
+ var result = await client.SendAsync(new HttpRequestMessage());
+
+ Assert.Equal(result.StatusCode, HttpStatusCode.OK);
+ Assert.Equal(2, innerHandler.InvocationCount);
+ }
+
+ [Fact]
+ public async Task ShouldRetryTheExpectedAmountOfTimesAndReturnTimeoutExceptionWhenTasksCancelled()
+ {
+ innerHandler.AddBehaviour(innerHandler.TaskCancelled);
+ innerHandler.AddBehaviour(innerHandler.TaskCancelled);
+
+ await Assert.ThrowsAsync(() => client.SendAsync(new HttpRequestMessage()));
+
+ Assert.Equal(2, innerHandler.InvocationCount);
+ }
+
+ [Fact]
+ public async Task ShouldRetryTheExpectedAmountOfTimesAndReturnExceptionWhenInternalServerErrorsEncountered()
+ {
+ innerHandler.AddBehaviour(innerHandler.InternalServerError);
+ innerHandler.AddBehaviour(innerHandler.ServiceUnavailable);
+
+ await Assert.ThrowsAsync(() => client.SendAsync(new HttpRequestMessage()));
+
+ Assert.Equal(2, innerHandler.InvocationCount);
+ }
+ }
+}
diff --git a/tests/SendGrid.Tests/Reliability/RetryTestBehaviourDelegatingHandler.cs b/tests/SendGrid.Tests/Reliability/RetryTestBehaviourDelegatingHandler.cs
new file mode 100644
index 000000000..b594ee789
--- /dev/null
+++ b/tests/SendGrid.Tests/Reliability/RetryTestBehaviourDelegatingHandler.cs
@@ -0,0 +1,80 @@
+namespace SendGrid.Tests.Reliability
+{
+ using System;
+ using System.Collections.Generic;
+ using System.Net;
+ using System.Net.Http;
+ using System.Threading;
+ using System.Threading.Tasks;
+
+ public class RetryTestBehaviourDelegatingHandler : DelegatingHandler
+ {
+ private readonly List>> behaviours;
+
+ public RetryTestBehaviourDelegatingHandler()
+ {
+ behaviours = new List>>();
+ }
+
+ public int InvocationCount { get; private set; }
+
+ public void AddBehaviour(Func> configuredBehavior)
+ {
+ Task behaviour()
+ {
+ InvocationCount++;
+ return configuredBehavior();
+ }
+
+ behaviours.Add(behaviour);
+ }
+
+ public Task OK()
+ {
+ return CreateHttpResponse(HttpStatusCode.OK);
+ }
+
+ public Task InternalServerError()
+ {
+ return CreateHttpResponse(HttpStatusCode.InternalServerError);
+ }
+ public Task ServiceUnavailable()
+ {
+ return CreateHttpResponse(HttpStatusCode.ServiceUnavailable);
+ }
+
+ public Task AuthenticationError()
+ {
+ return CreateHttpResponse(HttpStatusCode.Unauthorized);
+ }
+ public Task HttpVersionNotSupported()
+ {
+ return CreateHttpResponse(HttpStatusCode.HttpVersionNotSupported);
+ }
+ public Task NotImplemented()
+ {
+ return CreateHttpResponse(HttpStatusCode.NotImplemented);
+ }
+
+ public Task CreateHttpResponse(HttpStatusCode statusCode)
+ {
+ var httpResponseMessage = new HttpResponseMessage(statusCode) { Content = new StringContent(string.Empty) };
+ return Task.Factory.StartNew(() => httpResponseMessage);
+ }
+
+ public Task TaskCancelled()
+ {
+ throw new TaskCanceledException();
+ }
+
+ public Task NonTransientException()
+ {
+ throw new InvalidOperationException();
+ }
+
+ protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
+ {
+ return behaviours[InvocationCount]();
+ }
+ }
+}