Skip to content

Commit 3d5a8bf

Browse files
authored
feat: support default API rate limiting policy with Polly (#46)
- Add new `IGDBClient.CreateWithDefaults` static helper to create an IGDB client configured with all the defaults - Add default Polly API policy to `IGDB.ApiPolicy` static class and `DefaultApiPolicy` async policy - Add an overload for passing an `IAsyncPolicy<HttpResponseMessage>` to the client constructor
1 parent b63b890 commit 3d5a8bf

File tree

13 files changed

+188
-73
lines changed

13 files changed

+188
-73
lines changed

.github/workflows/dotnet.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
- name: Restore dependencies
1919
run: dotnet restore
2020
- name: Test
21-
run: dotnet test --verbosity normal
21+
run: dotnet test --verbosity normal --filter "Category!=SkipCi"
2222
env:
2323
IGDB_CLIENT_ID: ${{ secrets.IGDB_CLIENT_ID }}
2424
IGDB_CLIENT_SECRET: ${{ secrets.IGDB_CLIENT_SECRET }}

IGDB.Tests/AssemblyInfo.cs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
using Xunit;
2+
3+
[assembly: CollectionBehavior(DisableTestParallelization = true)]

IGDB.Tests/Dumps.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,19 +7,21 @@
77

88
namespace IGDB.Tests
99
{
10+
[Collection("/dumps")]
1011
public class Dumps
1112
{
1213
IGDBClient _api;
1314

1415
public Dumps()
1516
{
16-
_api = new IGDB.IGDBClient(
17+
_api = IGDBClient.CreateWithDefaults(
1718
Environment.GetEnvironmentVariable("IGDB_CLIENT_ID"),
1819
Environment.GetEnvironmentVariable("IGDB_CLIENT_SECRET")
1920
);
2021
}
2122

2223
[Fact]
24+
[Trait("Category", "SkipCi")]
2325
public async Task ShouldReturnDumpsList()
2426
{
2527
var dumps = await _api.GetDataDumpsAsync();
@@ -29,6 +31,7 @@ public async Task ShouldReturnDumpsList()
2931
}
3032

3133
[Fact]
34+
[Trait("Category", "SkipCi")]
3235
public async Task ShouldReturnGamesEndpointDump()
3336
{
3437
var gameDump = await _api.GetDataDumpEndpointAsync(IGDBClient.Endpoints.Games);

IGDB.Tests/GameTimeToBeats.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44

55
namespace IGDB.Tests
66
{
7+
[Collection("/game_time_to_beats")]
78
public class GameTimeToBeats
89
{
910
IGDBClient _api;
1011

1112
public GameTimeToBeats()
1213
{
13-
_api = new IGDB.IGDBClient(
14+
_api = IGDBClient.CreateWithDefaults(
1415
Environment.GetEnvironmentVariable("IGDB_CLIENT_ID"),
1516
Environment.GetEnvironmentVariable("IGDB_CLIENT_SECRET")
1617
);

IGDB.Tests/Games.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66

77
namespace IGDB.Tests
88
{
9+
[Collection("/games")]
910
public class Games
1011
{
1112
IGDBClient _api;
1213

1314
public Games()
1415
{
15-
_api = new IGDB.IGDBClient(
16+
_api = IGDBClient.CreateWithDefaults(
1617
Environment.GetEnvironmentVariable("IGDB_CLIENT_ID"),
1718
Environment.GetEnvironmentVariable("IGDB_CLIENT_SECRET")
1819
);

IGDB.Tests/IGDB.Tests.csproj

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
<TargetFramework>net9.0</TargetFramework>
55

66
<IsPackable>false</IsPackable>
7-
<DefineConstants>$(DefineConstants);IGDB_TESTS</DefineConstants>
87
</PropertyGroup>
98

109
<ItemGroup>

IGDB.Tests/Platforms.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,14 @@
66

77
namespace IGDB.Tests
88
{
9+
[Collection("/platforms")]
910
public class Platforms
1011
{
1112
IGDBClient _api;
1213

1314
public Platforms()
1415
{
15-
_api = new IGDB.IGDBClient(
16+
_api = IGDBClient.CreateWithDefaults(
1617
Environment.GetEnvironmentVariable("IGDB_CLIENT_ID"),
1718
Environment.GetEnvironmentVariable("IGDB_CLIENT_SECRET")
1819
);

IGDB.Tests/PopScore.cs

Lines changed: 43 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -6,53 +6,54 @@
66

77
namespace IGDB.Tests
88
{
9+
[Collection("/popularity_types")]
910
public class PopScore
10-
{
11-
IGDBClient _api;
11+
{
12+
IGDBClient _api;
1213

13-
public PopScore()
14-
{
15-
_api = new IGDB.IGDBClient(
16-
Environment.GetEnvironmentVariable("IGDB_CLIENT_ID"),
17-
Environment.GetEnvironmentVariable("IGDB_CLIENT_SECRET")
18-
);
19-
}
14+
public PopScore()
15+
{
16+
_api = IGDBClient.CreateWithDefaults(
17+
Environment.GetEnvironmentVariable("IGDB_CLIENT_ID"),
18+
Environment.GetEnvironmentVariable("IGDB_CLIENT_SECRET")
19+
);
20+
}
2021

21-
[Fact]
22-
public async Task ShouldReturnAllPopularityTypes()
23-
{
24-
var popularityTypes = await _api.QueryAsync<PopularityType>(IGDBClient.Endpoints.PopularityTypes, "fields *;");
22+
[Fact]
23+
public async Task ShouldReturnAllPopularityTypes()
24+
{
25+
var popularityTypes = await _api.QueryAsync<PopularityType>(IGDBClient.Endpoints.PopularityTypes, "fields *;");
2526

26-
Assert.NotNull(popularityTypes);
27-
foreach (var popularityType in popularityTypes)
28-
{
29-
Assert.NotNull(popularityType.Checksum);
30-
Assert.NotNull(popularityType.CreatedAt);
31-
Assert.NotNull(popularityType.Name);
32-
Assert.NotNull(popularityType.ExternalPopularitySource.Id);
33-
Assert.NotNull(popularityType.UpdatedAt);
34-
}
35-
}
27+
Assert.NotNull(popularityTypes);
28+
foreach (var popularityType in popularityTypes)
29+
{
30+
Assert.NotNull(popularityType.Checksum);
31+
Assert.NotNull(popularityType.CreatedAt);
32+
Assert.NotNull(popularityType.Name);
33+
Assert.NotNull(popularityType.ExternalPopularitySource.Id);
34+
Assert.NotNull(popularityType.UpdatedAt);
35+
}
36+
}
3637

37-
[Fact]
38-
public async Task ShouldReturnLimitedPopularityPrimitives()
39-
{
40-
var popularityPrimitives = await _api.QueryAsync<PopularityPrimitive>(
41-
IGDBClient.Endpoints.PopularityPrimitives,
42-
"fields *; limit 10;");
38+
[Fact]
39+
public async Task ShouldReturnLimitedPopularityPrimitives()
40+
{
41+
var popularityPrimitives = await _api.QueryAsync<PopularityPrimitive>(
42+
IGDBClient.Endpoints.PopularityPrimitives,
43+
"fields *; limit 10;");
4344

44-
Assert.NotNull(popularityPrimitives);
45-
Assert.True(popularityPrimitives.Length == 10);
45+
Assert.NotNull(popularityPrimitives);
46+
Assert.True(popularityPrimitives.Length == 10);
4647

47-
foreach (var popularityPrimitive in popularityPrimitives)
48-
{
49-
Assert.NotNull(popularityPrimitive.CalculatedAt);
50-
Assert.NotNull(popularityPrimitive.CreatedAt);
51-
Assert.NotNull(popularityPrimitive.ExternalPopularitySource.Id);
52-
Assert.NotNull(popularityPrimitive.PopularityType);
53-
Assert.NotNull(popularityPrimitive.UpdatedAt);
54-
Assert.NotNull(popularityPrimitive.Value);
55-
}
56-
}
57-
}
48+
foreach (var popularityPrimitive in popularityPrimitives)
49+
{
50+
Assert.NotNull(popularityPrimitive.CalculatedAt);
51+
Assert.NotNull(popularityPrimitive.CreatedAt);
52+
Assert.NotNull(popularityPrimitive.ExternalPopularitySource.Id);
53+
Assert.NotNull(popularityPrimitive.PopularityType);
54+
Assert.NotNull(popularityPrimitive.UpdatedAt);
55+
Assert.NotNull(popularityPrimitive.Value);
56+
}
57+
}
58+
}
5859
}

IGDB.Tests/TokenHandling.cs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,8 @@ public async Task ShouldHandleInvalidTokenAndRetryRequest()
1818
var invalidTokenClient = new IGDB.IGDBClient(
1919
Environment.GetEnvironmentVariable("IGDB_CLIENT_ID"),
2020
Environment.GetEnvironmentVariable("IGDB_CLIENT_SECRET"),
21-
tokenStore
21+
tokenStore,
22+
ApiPolicy.DefaultApiPolicy
2223
);
2324

2425
var games = await invalidTokenClient.QueryAsync<Game>("games");
@@ -34,7 +35,8 @@ public async Task ShouldHandleExpiredTokenAndAcquireNewOne()
3435
var client = new IGDB.IGDBClient(
3536
Environment.GetEnvironmentVariable("IGDB_CLIENT_ID"),
3637
Environment.GetEnvironmentVariable("IGDB_CLIENT_SECRET"),
37-
tokenStore
38+
tokenStore,
39+
ApiPolicy.DefaultApiPolicy
3840
);
3941

4042
await client.QueryAsync<Game>("games");
@@ -51,7 +53,8 @@ public async Task ShouldUseExistingTokenWhenNotExpired()
5153
var client = new IGDB.IGDBClient(
5254
Environment.GetEnvironmentVariable("IGDB_CLIENT_ID"),
5355
Environment.GetEnvironmentVariable("IGDB_CLIENT_SECRET"),
54-
tokenStore
56+
tokenStore,
57+
ApiPolicy.DefaultApiPolicy
5558
);
5659

5760
await client.QueryAsync<Game>("games");

IGDB/ApiPolicy.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
using System;
2+
using System.Net.Http;
3+
using System.Threading.Tasks;
4+
using Polly;
5+
using Polly.Bulkhead;
6+
using Polly.RateLimit;
7+
8+
namespace IGDB
9+
{
10+
public static class ApiPolicy
11+
{
12+
private static readonly Random _jitter = new Random();
13+
private const int MaxRetries = 3;
14+
private const double RetryDelayBaseSeconds = 0.5;
15+
private const int MaxParallelization = 6;
16+
private const int MaxQueuingActions = 32;
17+
private const int MaxRateLimit = 4;
18+
private const int JitterMs = 500;
19+
private static readonly TimeSpan RateLimitPeriod = TimeSpan.FromMilliseconds(900);
20+
21+
/// <summary>
22+
/// Default API policy for handling HTTP requests to IGDB.
23+
///
24+
/// This policy includes:
25+
/// - Retry logic for rate limit and bulkhead exceptions with exponential backoff and jitter.
26+
/// - Bulkhead isolation to limit the number of concurrent requests.
27+
/// - Rate limiting to control the request rate.
28+
///
29+
/// The retry logic will attempt to retry up to 3 times with an exponential backoff strategy,
30+
/// adding a random jitter of up to ±500ms to avoid thundering herd problems.
31+
/// </summary>
32+
public static readonly IAsyncPolicy<HttpResponseMessage> DefaultApiPolicy
33+
= Policy.WrapAsync(
34+
Policy<HttpResponseMessage>.Handle<RateLimitRejectedException>()
35+
.Or<BulkheadRejectedException>()
36+
.WaitAndRetryAsync(
37+
retryCount: MaxRetries,
38+
sleepDurationProvider: (retryAttempt) =>
39+
{
40+
var backOff = TimeSpan.FromSeconds(RetryDelayBaseSeconds * Math.Pow(2, retryAttempt - 1));
41+
var jitter = TimeSpan.FromMilliseconds(_jitter.Next(-JitterMs, JitterMs));
42+
return backOff + jitter;
43+
},
44+
onRetry: (_, __, ___) => { }
45+
),
46+
Policy.BulkheadAsync<HttpResponseMessage>(maxParallelization: MaxParallelization, maxQueuingActions: MaxQueuingActions),
47+
Policy.RateLimitAsync<HttpResponseMessage>(MaxRateLimit, RateLimitPeriod)
48+
);
49+
}
50+
}

0 commit comments

Comments
 (0)