Skip to content

Commit cde6ed3

Browse files
committed
Merge branch 'develop' into stable
2 parents 3f6c42d + 7cb42a0 commit cde6ed3

File tree

9 files changed

+116
-33
lines changed

9 files changed

+116
-33
lines changed

Client/Client.csproj

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,19 @@
11
<Project Sdk="Microsoft.NET.Sdk">
2-
32
<PropertyGroup>
43
<TargetFrameworks>netstandard1.3;netstandard2.0;net45</TargetFrameworks>
54
<AssemblyName>Pathoschild.Http.Client</AssemblyName>
65
<RootNamespace>Pathoschild.Http.Client</RootNamespace>
76
<PackageId>Pathoschild.Http.FluentClient</PackageId>
87
<Title>FluentHttpClient</Title>
9-
<Version>4.0.0</Version>
8+
<Version>4.1.0</Version>
109
<Authors>Pathoschild</Authors>
1110
<Description>A modern async HTTP client for REST APIs. Its fluent interface lets you send an HTTP request and parse the response in one go.</Description>
1211
<PackageLicenseExpression>MIT</PackageLicenseExpression>
1312
<PackageProjectUrl>https://github.com/Pathoschild/FluentHttpClient#readme</PackageProjectUrl>
1413
<PackageIcon>images/package-icon.png</PackageIcon>
1514
<RepositoryType>git</RepositoryType>
1615
<RepositoryUrl>https://github.com/Pathoschild/FluentHttpClient.git</RepositoryUrl>
17-
<PackageReleaseNotes>See release notes at https://github.com/Pathoschild/FluentHttpClient/blob/develop/RELEASE-NOTES.md#40</PackageReleaseNotes>
16+
<PackageReleaseNotes>See release notes at https://github.com/Pathoschild/FluentHttpClient/blob/develop/RELEASE-NOTES.md</PackageReleaseNotes>
1817
<PackageTags>wcf;web;webapi;HttpClient;FluentHttp;FluentHttpClient</PackageTags>
1918
<GenerateDocumentationFile>true</GenerateDocumentationFile>
2019
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
@@ -36,5 +35,4 @@
3635
<ItemGroup>
3736
<None Include="package-icon.png" Pack="true" PackagePath="images/" />
3837
</ItemGroup>
39-
4038
</Project>

Client/FluentClientExtensions.cs

Lines changed: 27 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
using System.IO;
44
using System.Linq;
55
using System.Net.Http;
6-
using System.Reflection;
76
using System.Text;
87
using System.Threading.Tasks;
98
using Pathoschild.Http.Client.Extensibility;
@@ -171,12 +170,20 @@ public static IClient SetRequestCoordinator(this IClient client, int maxRetries,
171170

172171
/// <summary>Set the default request coordinator.</summary>
173172
/// <param name="client">The client.</param>
174-
/// <param name="config">The retry configuration (or null for the default coordinator).</param>
173+
/// <param name="config">The retry configuration to apply (or null for the default coordinator).</param>
175174
public static IClient SetRequestCoordinator(this IClient client, IRetryConfig? config)
176175
{
177176
return client.SetRequestCoordinator(new RetryCoordinator(config));
178177
}
179178

179+
/// <summary>Set the default request coordinator.</summary>
180+
/// <param name="client">The client.</param>
181+
/// <param name="configs">The retry configurations to apply (or null for the default behavior). Each configuration will have the opportunity to retry a request.</param>
182+
public static IClient SetRequestCoordinator(this IClient client, IEnumerable<IRetryConfig?>? configs)
183+
{
184+
return client.SetRequestCoordinator(new RetryCoordinator(configs));
185+
}
186+
180187
/// <summary>Set default options for all requests.</summary>
181188
/// <param name="client">The client.</param>
182189
/// <param name="ignoreHttpErrors">Whether to ignore null arguments when the request is dispatched (or <c>null</c> to leave the option unchanged).</param>
@@ -217,18 +224,15 @@ public static IRequest WithBearerAuthentication(this IRequest request, string to
217224
/// <exception cref="InvalidOperationException">No MediaTypeFormatters are available on the API client for this content type.</exception>
218225
public static IRequest WithBody<T>(this IRequest request, T body)
219226
{
220-
if (body == null)
221-
throw new ArgumentNullException(nameof(body));
222-
223-
// HttpContent
224-
if (typeof(HttpContent).GetTypeInfo().IsAssignableFrom(typeof(T).GetTypeInfo()))
225-
return request.WithBody(p => (HttpContent)(object)body);
226-
227-
// model
228-
return request.WithBody(p => p.Model(body));
227+
return request.WithBody(builder => body switch
228+
{
229+
null => null,
230+
HttpContent content => content,
231+
_ => builder.Model(body)
232+
});
229233
}
230234

231-
/// <summary>Set the request coordinator for this request</summary>
235+
/// <summary>Set the request coordinator for this request.</summary>
232236
/// <param name="request">The request.</param>
233237
/// <param name="shouldRetry">A lambda which returns whether a request should be retried.</param>
234238
/// <param name="intervals">The intervals between each retry attempt.</param>
@@ -237,7 +241,7 @@ public static IRequest WithRequestCoordinator(this IRequest request, Func<HttpRe
237241
return request.WithRequestCoordinator(new RetryCoordinator(shouldRetry, intervals));
238242
}
239243

240-
/// <summary>Set the request coordinator for this request</summary>
244+
/// <summary>Set the request coordinator for this request.</summary>
241245
/// <param name="request">The request.</param>
242246
/// <param name="maxRetries">The maximum number of times to retry a request before failing.</param>
243247
/// <param name="shouldRetry">A method which returns whether a request should be retried.</param>
@@ -247,14 +251,22 @@ public static IRequest WithRequestCoordinator(this IRequest request, int maxRetr
247251
return request.WithRequestCoordinator(new RetryCoordinator(maxRetries, shouldRetry, getDelay));
248252
}
249253

250-
/// <summary>Set the request coordinator for this request</summary>
254+
/// <summary>Set the request coordinator for this request.</summary>
251255
/// <param name="request">The request.</param>
252-
/// <param name="config">The retry config (or null to use the default behaviour).</param>
256+
/// <param name="config">The retry config (or null for the default behavior).</param>
253257
public static IRequest WithRequestCoordinator(this IRequest request, IRetryConfig? config)
254258
{
255259
return request.WithRequestCoordinator(new RetryCoordinator(config));
256260
}
257261

262+
/// <summary>Set the request coordinator for this request.</summary>
263+
/// <param name="request">The request.</param>
264+
/// <param name="configs">The retry configurations to apply (or null for the default behavior). Each configuration will have the opportunity to retry a request.</param>
265+
public static IRequest WithRequestCoordinator(this IRequest request, IEnumerable<IRetryConfig?>? configs)
266+
{
267+
return request.WithRequestCoordinator(new RetryCoordinator(configs));
268+
}
269+
258270
/// <summary>Set options for this request.</summary>
259271
/// <param name="request">The request.</param>
260272
/// <param name="ignoreHttpErrors">Whether to ignore null arguments when the request is dispatched (or <c>null</c> to leave the option unchanged).</param>

Client/IRequest.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ public interface IRequest
4343
/// <summary>Set the body content of the HTTP request.</summary>
4444
/// <param name="bodyBuilder">The HTTP body builder.</param>
4545
/// <returns>Returns the request builder for chaining.</returns>
46-
IRequest WithBody(Func<IBodyBuilder, HttpContent> bodyBuilder);
46+
IRequest WithBody(Func<IBodyBuilder, HttpContent?> bodyBuilder);
4747

4848
/// <summary>Set an HTTP header.</summary>
4949
/// <param name="key">The key of the HTTP header.</param>

Client/Internal/Request.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ public Request(HttpRequestMessage message, MediaTypeFormatterCollection formatte
7171
/// <summary>Set the body content of the HTTP request.</summary>
7272
/// <param name="bodyBuilder">The HTTP body builder.</param>
7373
/// <returns>Returns the request builder for chaining.</returns>
74-
public IRequest WithBody(Func<IBodyBuilder, HttpContent> bodyBuilder)
74+
public IRequest WithBody(Func<IBodyBuilder, HttpContent?> bodyBuilder)
7575
{
7676
this.Message.Content = bodyBuilder(new BodyBuilder(this));
7777
return this;

Client/Retry/RetryCoordinator.cs

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
24
using System.Net;
35
using System.Net.Http;
46
using System.Threading.Tasks;
@@ -12,8 +14,8 @@ public class RetryCoordinator : IRequestCoordinator
1214
/*********
1315
** Fields
1416
*********/
15-
/// <summary>The retry configuration.</summary>
16-
private readonly IRetryConfig Config;
17+
/// <summary>The retry configurations to apply.</summary>
18+
private readonly IRetryConfig[] Configs;
1719

1820
/// <summary>The status code representing a request timeout.</summary>
1921
/// <remarks>HTTP 598 Network Read Timeout is the closest match, though it's non-standard so there's no <see cref="HttpStatusCode"/> constant. This is needed to avoid passing <c>null</c> into <see cref="IRetryConfig.ShouldRetry"/>, which isn't intuitive and would cause errors.</remarks>
@@ -37,10 +39,18 @@ public RetryCoordinator(int maxRetries, Func<HttpResponseMessage, bool> shouldRe
3739
: this(new RetryConfig(maxRetries, shouldRetry, getDelay)) { }
3840

3941
/// <summary>Construct an instance.</summary>
40-
/// <param name="config">The retry configuration.</param>
42+
/// <param name="config">The retry configuration to apply.</param>
4143
public RetryCoordinator(IRetryConfig? config)
44+
: this(new[] { config }) { }
45+
46+
/// <summary>Construct an instance.</summary>
47+
/// <param name="configs">The retry configurations to apply. Each config will be given the opportunity to retry a request.</param>
48+
public RetryCoordinator(IEnumerable<IRetryConfig?>? configs)
4249
{
43-
this.Config = config ?? RetryConfig.None();
50+
this.Configs = configs
51+
?.Where(config => config != null)
52+
.Select(config => config!)
53+
.ToArray() ?? new IRetryConfig[0];
4454
}
4555

4656
/// <summary>Dispatch an HTTP request.</summary>
@@ -50,7 +60,6 @@ public RetryCoordinator(IRetryConfig? config)
5060
public async Task<HttpResponseMessage> ExecuteAsync(IRequest request, Func<IRequest, Task<HttpResponseMessage>> dispatcher)
5161
{
5262
int attempt = 0;
53-
int maxAttempt = 1 + this.Config.MaxRetries;
5463
while (true)
5564
{
5665
// dispatch request
@@ -65,14 +74,28 @@ public async Task<HttpResponseMessage> ExecuteAsync(IRequest request, Func<IRequ
6574
response = request.Message.CreateResponse(this.TimeoutStatusCode);
6675
}
6776

68-
// exit if done
69-
if (!this.Config.ShouldRetry(response))
77+
// find the applicable retry configuration
78+
IRetryConfig? retryConfig = null;
79+
foreach (var config in this.Configs)
80+
{
81+
if (config.ShouldRetry(response))
82+
{
83+
retryConfig = config;
84+
break;
85+
}
86+
}
87+
88+
// exit if we can't retry
89+
if (retryConfig == null)
7090
return response;
91+
92+
// throw exception if we've exceeded max retries
93+
int maxAttempt = 1 + retryConfig.MaxRetries;
7194
if (attempt >= maxAttempt)
72-
throw new ApiException(new Response(response, request.Formatters), $"The HTTP request {(response != null ? "failed" : "timed out")}, and the retry coordinator gave up after the maximum {this.Config.MaxRetries} retries");
95+
throw new ApiException(new Response(response, request.Formatters), $"The HTTP request {(response != null ? "failed" : "timed out")}, and the retry coordinator gave up after the maximum {retryConfig.MaxRetries} retries");
7396

7497
// set up retry
75-
TimeSpan delay = this.Config.GetDelay(attempt, response);
98+
TimeSpan delay = retryConfig.GetDelay(attempt, response);
7699
if (delay.TotalMilliseconds > 0)
77100
await Task.Delay(delay).ConfigureAwait(false);
78101
}

README.md

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ The client works on most platforms (including Linux, Mac, and Windows):
4444

4545
| platform | min version |
4646
| :-------------------------- | :---------- |
47+
| .NET | 5.0 |
4748
| .NET Core | 1.0 |
4849
| .NET Framework | 4.5 |
4950
| [.NET Standard][] | 1.3 |
@@ -224,6 +225,51 @@ client
224225
);
225226
```
226227

228+
### Chained retry policies
229+
You can also wrap retry logic into `IRetryConfig` implementations:
230+
231+
```c#
232+
/// <summary>A retry policy which retries with incremental backoff.</summary>
233+
public class RetryWithBackoffConfig : IRetryConfig
234+
{
235+
/// <summary>The maximum number of times to retry a request before failing.</summary>
236+
public int MaxRetries => 3;
237+
238+
/// <summary>Get whether a request should be retried.</summary>
239+
/// <param name="response">The last HTTP response received.</param>
240+
public bool ShouldRetry(HttpResponseMessage response)
241+
{
242+
return request.StatusCode != HttpStatusCode.OK;
243+
}
244+
245+
/// <summary>Get the time to wait until the next retry.</summary>
246+
/// <param name="attempt">The retry index (starting at 1).</param>
247+
/// <param name="response">The last HTTP response received.</param>
248+
public TimeSpan GetDelay(int attempt, HttpResponseMessage response)
249+
{
250+
return TimeSpan.FromSeconds(attempt * 5); // wait 5, 10, and 15 seconds
251+
}
252+
}
253+
```
254+
255+
Then you can add one or more retry policies, and they'll each be given the opportunity to retry
256+
a request:
257+
258+
```c#
259+
client
260+
.SetRequestCoordinator(new[]
261+
{
262+
new TokenExpiredRetryConfig(),
263+
new DatabaseTimeoutRetryConfig(),
264+
new RetryWithBackoffConfig()
265+
});
266+
```
267+
268+
Note that there's one retry count across all retry policies. For example, if
269+
`TokenExpiredRetryConfig` retries once before falling back to `RetryWithBackoffConfig`, the latter
270+
will receive `2` as its first retry count. If you need more granular control, see [_custom
271+
retry/coordination policy_](#custom-retrycoordination-policy).
272+
227273
### Cancellation tokens
228274
The client fully supports [.NET cancellation tokens](https://msdn.microsoft.com/en-us/library/dd997364.aspx)
229275
if you need to abort requests:

RELEASE-NOTES.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
# Release notes
2+
## 4.1
3+
Released 11 March 2021.
4+
5+
* Added support for [chained retry policies](README.md#chained-retry-policies) (thanks to Jericho!).
6+
* Fixed `WithBody(null)` no longer allowed in 4.0.
7+
28
## 4.0
39
Released 13 May 2020.
410

Tests/Client/RequestTests.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -551,7 +551,7 @@ public void WithHeader(string methodName, string key, string value)
551551
IRequest request = this
552552
.ConstructRequest(methodName)
553553
.WithHeader(key, value);
554-
var header = request.Message.Headers.FirstOrDefault(p => p.Key == key);
554+
var header = request.Message.Headers.FirstOrDefault(p => string.Equals(p.Key, key, StringComparison.OrdinalIgnoreCase));
555555

556556
// assert
557557
this.AssertEqual(request.Message, methodName, ignoreArguments: true);

Tests/Tests.csproj

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
<Project Sdk="Microsoft.NET.Sdk">
2-
32
<PropertyGroup>
43
<TargetFrameworks>netcoreapp1.1;netcoreapp2.0;net451</TargetFrameworks>
54
<AssemblyName>Pathoschild.Http.Tests</AssemblyName>
@@ -32,5 +31,4 @@
3231
<ItemGroup>
3332
<Service Include="{82a7f48d-3b50-4b1e-b82e-3ada8210c358}" />
3433
</ItemGroup>
35-
3634
</Project>

0 commit comments

Comments
 (0)