Skip to content

Commit 59ef966

Browse files
committed
Merge remote-tracking branch 'origin/users/yjorayev/dotnet-ratelimit' into users/yjorayev/dotnet-ratelimit
# Conflicts: # src/Ocelot/Configuration/File/FileRateLimitRule.cs
2 parents 8824cee + 72fb411 commit 59ef966

File tree

9 files changed

+138
-46
lines changed

9 files changed

+138
-46
lines changed

src/Ocelot/Configuration/RateLimitOptions.cs

+3-3
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public RateLimitOptions(bool enableRateLimiting, string clientIdHeader, Func<Lis
3333
/// Gets the list of white listed clients.
3434
/// </summary>
3535
/// <value>
36-
/// A <see cref="List{T}"/> collection with white listed clients.
36+
/// A <see cref="List{T}"/> (where T is <see cref="string"/>) collection with white listed clients.
3737
/// </value>
3838
public List<string> ClientWhitelist => _getClientWhitelist();
3939

@@ -81,10 +81,10 @@ public RateLimitOptions(bool enableRateLimiting, string clientIdHeader, Func<Lis
8181
public bool EnableRateLimiting { get; }
8282

8383
/// <summary>
84-
/// Disables X-Rate-Limit and Rety-After headers.
84+
/// Disables <c>X-Rate-Limit</c> and <c>Retry-After</c> headers.
8585
/// </summary>
8686
/// <value>
87-
/// A boolean value for disabling X-Rate-Limit and Rety-After headers.
87+
/// A boolean value for disabling <c>X-Rate-Limit</c> and <c>Retry-After</c> headers.
8888
/// </value>
8989
public bool DisableRateLimitHeaders { get; }
9090

src/Ocelot/RateLimiting/Middleware/RateLimitingMiddleware.cs

+32-27
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
#if NET7_0_OR_GREATER
1+
using Microsoft.AspNetCore.Http;
2+
#if NET7_0_OR_GREATER
23
using Microsoft.AspNetCore.RateLimiting;
34
#endif
4-
using Microsoft.AspNetCore.Http;
5+
using Microsoft.Net.Http.Headers;
56
using Ocelot.Configuration;
67
using Ocelot.Logging;
78
using Ocelot.Middleware;
@@ -13,15 +14,18 @@ public class RateLimitingMiddleware : OcelotMiddleware
1314
{
1415
private readonly RequestDelegate _next;
1516
private readonly IRateLimiting _limiter;
17+
private readonly IHttpContextAccessor _contextAccessor;
1618

1719
public RateLimitingMiddleware(
1820
RequestDelegate next,
1921
IOcelotLoggerFactory factory,
20-
IRateLimiting limiter)
22+
IRateLimiting limiter,
23+
IHttpContextAccessor contextAccessor)
2124
: base(factory.CreateLogger<RateLimitingMiddleware>())
2225
{
2326
_next = next;
2427
_limiter = limiter;
28+
_contextAccessor = contextAccessor;
2529
}
2630

2731
public async Task Invoke(HttpContext httpContext)
@@ -83,11 +87,15 @@ public async Task Invoke(HttpContext httpContext)
8387
}
8488
}
8589

86-
//set X-Rate-Limit headers for the longest period
90+
// Set X-Rate-Limit headers for the longest period
8791
if (!options.DisableRateLimitHeaders)
8892
{
89-
var headers = _limiter.GetHeaders(httpContext, identity, options);
90-
httpContext.Response.OnStarting(SetRateLimitHeaders, state: headers);
93+
var originalContext = _contextAccessor?.HttpContext;
94+
if (originalContext != null)
95+
{
96+
var headers = _limiter.GetHeaders(originalContext, identity, options);
97+
originalContext.Response.OnStarting(SetRateLimitHeaders, state: headers);
98+
}
9199
}
92100

93101
await _next.Invoke(httpContext);
@@ -108,15 +116,8 @@ public virtual ClientRequestIdentity SetIdentity(HttpContext httpContext, RateLi
108116
);
109117
}
110118

111-
public bool IsWhitelisted(ClientRequestIdentity requestIdentity, RateLimitOptions option)
112-
{
113-
if (option.ClientWhitelist.Contains(requestIdentity.ClientId))
114-
{
115-
return true;
116-
}
117-
118-
return false;
119-
}
119+
public static bool IsWhitelisted(ClientRequestIdentity requestIdentity, RateLimitOptions option)
120+
=> option.ClientWhitelist.Contains(requestIdentity.ClientId);
120121

121122
public virtual void LogBlockedRequest(HttpContext httpContext, ClientRequestIdentity identity, RateLimitCounter counter, RateLimitRule rule, DownstreamRoute downstreamRoute)
122123
{
@@ -127,14 +128,15 @@ public virtual void LogBlockedRequest(HttpContext httpContext, ClientRequestIden
127128
public virtual DownstreamResponse ReturnQuotaExceededResponse(HttpContext httpContext, RateLimitOptions option, string retryAfter)
128129
{
129130
var message = GetResponseMessage(option);
130-
131-
var http = new HttpResponseMessage((HttpStatusCode)option.HttpStatusCode);
132-
133-
http.Content = new StringContent(message);
131+
var http = new HttpResponseMessage((HttpStatusCode)option.HttpStatusCode)
132+
{
133+
Content = new StringContent(message),
134+
};
134135

135136
if (!option.DisableRateLimitHeaders)
136137
{
137-
http.Headers.TryAddWithoutValidation("Retry-After", retryAfter); // in seconds, not date string
138+
http.Headers.TryAddWithoutValidation(HeaderNames.RetryAfter, retryAfter); // in seconds, not date string
139+
httpContext.Response.Headers[HeaderNames.RetryAfter] = retryAfter;
138140
}
139141

140142
return new DownstreamResponse(http);
@@ -148,14 +150,17 @@ private static string GetResponseMessage(RateLimitOptions option)
148150
return message;
149151
}
150152

151-
private static Task SetRateLimitHeaders(object rateLimitHeaders)
153+
/// <summary>TODO: Produced Ocelot's headers don't follow industry standards.</summary>
154+
/// <remarks>More details in <see cref="RateLimitingHeaders"/> docs.</remarks>
155+
/// <param name="state">Captured state as a <see cref="RateLimitHeaders"/> object.</param>
156+
/// <returns>The <see cref="Task.CompletedTask"/> object.</returns>
157+
private static Task SetRateLimitHeaders(object state)
152158
{
153-
var headers = (RateLimitHeaders)rateLimitHeaders;
154-
155-
headers.Context.Response.Headers["X-Rate-Limit-Limit"] = headers.Limit;
156-
headers.Context.Response.Headers["X-Rate-Limit-Remaining"] = headers.Remaining;
157-
headers.Context.Response.Headers["X-Rate-Limit-Reset"] = headers.Reset;
158-
159+
var limitHeaders = (RateLimitHeaders)state;
160+
var headers = limitHeaders.Context.Response.Headers;
161+
headers[RateLimitingHeaders.X_Rate_Limit_Limit] = limitHeaders.Limit;
162+
headers[RateLimitingHeaders.X_Rate_Limit_Remaining] = limitHeaders.Remaining;
163+
headers[RateLimitingHeaders.X_Rate_Limit_Reset] = limitHeaders.Reset;
159164
return Task.CompletedTask;
160165
}
161166
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
using Microsoft.Net.Http.Headers;
2+
3+
namespace Ocelot.RateLimiting;
4+
5+
/// <summary>
6+
/// TODO These Ocelot's RateLimiting headers don't follow industry standards, see links.
7+
/// </summary>
8+
/// <remarks>Links:
9+
/// <list type="bullet">
10+
/// <item>GitHub: <see href="https://github.com/ioggstream/draft-polli-ratelimit-headers">draft-polli-ratelimit-headers</see></item>
11+
/// <item>GitHub: <see href="https://github.com/ietf-wg-httpapi/ratelimit-headers">ratelimit-headers</see></item>
12+
/// <item>GitHub Wiki: <see href="https://ietf-wg-httpapi.github.io/ratelimit-headers/draft-ietf-httpapi-ratelimit-headers.html">RateLimit header fields for HTTP</see></item>
13+
/// <item>StackOverflow: <see href="https://stackoverflow.com/questions/16022624/examples-of-http-api-rate-limiting-http-response-headers">Examples of HTTP API Rate Limiting HTTP Response headers</see></item>
14+
/// </list>
15+
/// </remarks>
16+
public static class RateLimitingHeaders
17+
{
18+
public const char Dash = '-';
19+
public const char Underscore = '_';
20+
21+
/// <summary>Gets the <c>Retry-After</c> HTTP header name.</summary>
22+
public static readonly string Retry_After = HeaderNames.RetryAfter;
23+
24+
/// <summary>Gets the <c>X-Rate-Limit-Limit</c> Ocelot's header name.</summary>
25+
public static readonly string X_Rate_Limit_Limit = nameof(X_Rate_Limit_Limit).Replace(Underscore, Dash);
26+
27+
/// <summary>Gets the <c>X-Rate-Limit-Remaining</c> Ocelot's header name.</summary>
28+
public static readonly string X_Rate_Limit_Remaining = nameof(X_Rate_Limit_Remaining).Replace(Underscore, Dash);
29+
30+
/// <summary>Gets the <c>X-Rate-Limit-Reset</c> Ocelot's header name.</summary>
31+
public static readonly string X_Rate_Limit_Reset = nameof(X_Rate_Limit_Reset).Replace(Underscore, Dash);
32+
}

test/Ocelot.AcceptanceTests/RateLimiting/ClientRateLimitingTests.cs

+50-1
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
using Microsoft.AspNetCore.Http;
2+
using Microsoft.Net.Http.Headers;
23
using Ocelot.Configuration.File;
4+
using Ocelot.RateLimiting;
35

46
namespace Ocelot.AcceptanceTests.RateLimiting;
57

6-
public sealed class ClientRateLimitingTests : Steps, IDisposable
8+
public sealed class ClientRateLimitingTests : RateLimitingSteps, IDisposable
79
{
810
const int OK = (int)HttpStatusCode.OK;
911
const int TooManyRequests = (int)HttpStatusCode.TooManyRequests;
@@ -129,6 +131,53 @@ public void StatusShouldNotBeEqualTo429_PeriodTimespanValueIsGreaterThanPeriod()
129131
.And(x => ThenTheResponseBodyShouldBe("101")) // total 101 OK responses
130132
.BDDfy();
131133
}
134+
135+
[Theory]
136+
[Trait("Bug", "1305")]
137+
[InlineData(false)]
138+
[InlineData(true)]
139+
public void Should_set_ratelimiting_headers_on_response_when_DisableRateLimitHeaders_set_to(bool disableRateLimitHeaders)
140+
{
141+
int port = PortFinder.GetRandomPort();
142+
var configuration = CreateConfigurationForCheckingHeaders(port, disableRateLimitHeaders);
143+
bool exist = !disableRateLimitHeaders;
144+
this.Given(x => x.GivenThereIsAServiceRunningOn(DownstreamUrl(port), "/api/ClientRateLimit"))
145+
.And(x => GivenThereIsAConfiguration(configuration))
146+
.And(x => GivenOcelotIsRunning())
147+
.When(x => WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/api/ClientRateLimit", 1))
148+
.Then(x => ThenRateLimitingHeadersExistInResponse(exist))
149+
.And(x => ThenRetryAfterHeaderExistsInResponse(false))
150+
.When(x => WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/api/ClientRateLimit", 2))
151+
.Then(x => ThenRateLimitingHeadersExistInResponse(exist))
152+
.And(x => ThenRetryAfterHeaderExistsInResponse(false))
153+
.When(x => WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit("/api/ClientRateLimit", 1))
154+
.Then(x => ThenRateLimitingHeadersExistInResponse(false))
155+
.And(x => ThenRetryAfterHeaderExistsInResponse(exist))
156+
.BDDfy();
157+
}
158+
159+
private FileConfiguration CreateConfigurationForCheckingHeaders(int port, bool disableRateLimitHeaders)
160+
{
161+
var route = GivenRoute(port, null, null, new(), 3, "100s", 1000.0D);
162+
var config = GivenConfiguration(route);
163+
config.GlobalConfiguration.RateLimitOptions = new FileRateLimitOptions()
164+
{
165+
DisableRateLimitHeaders = disableRateLimitHeaders,
166+
QuotaExceededMessage = "",
167+
HttpStatusCode = TooManyRequests,
168+
};
169+
return config;
170+
}
171+
172+
private void ThenRateLimitingHeadersExistInResponse(bool headersExist)
173+
{
174+
_response.Headers.Contains(RateLimitingHeaders.X_Rate_Limit_Limit).ShouldBe(headersExist);
175+
_response.Headers.Contains(RateLimitingHeaders.X_Rate_Limit_Remaining).ShouldBe(headersExist);
176+
_response.Headers.Contains(RateLimitingHeaders.X_Rate_Limit_Reset).ShouldBe(headersExist);
177+
}
178+
179+
private void ThenRetryAfterHeaderExistsInResponse(bool headersExist)
180+
=> _response.Headers.Contains(HeaderNames.RetryAfter).ShouldBe(headersExist);
132181

133182
private void GivenThereIsAServiceRunningOn(string baseUrl, string basePath)
134183
{
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace Ocelot.AcceptanceTests.RateLimiting;
2+
3+
public class RateLimitingSteps : Steps
4+
{
5+
public async Task WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit(string url, int times)
6+
{
7+
for (var i = 0; i < times; i++)
8+
{
9+
const string clientId = "ocelotclient1";
10+
var request = new HttpRequestMessage(new HttpMethod("GET"), url);
11+
request.Headers.Add("ClientId", clientId);
12+
_response = await _ocelotClient.SendAsync(request);
13+
}
14+
}
15+
}

test/Ocelot.AcceptanceTests/RateLimiting/RateLimitingTests.cs

+1-2
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,12 @@
55
#endif
66
using Microsoft.AspNetCore.Builder;
77
using Microsoft.AspNetCore.Http;
8-
using Ocelot.Configuration;
98
using Ocelot.Configuration.File;
109
using Ocelot.DependencyInjection;
1110

1211
namespace Ocelot.AcceptanceTests.RateLimiting
1312
{
14-
public class RateLimitingTests: Steps
13+
public class AspNetRateLimitingTests: RateLimitingSteps
1514
{
1615
private const string _rateLimitPolicyName = "RateLimitPolicy";
1716
private const int _rateLimitLimit = 3;

test/Ocelot.AcceptanceTests/ServiceDiscovery/ConsulConfigurationInConsulTests.cs

+2-1
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
using Microsoft.AspNetCore.TestHost;
66
using Microsoft.Extensions.Configuration;
77
using Newtonsoft.Json;
8+
using Ocelot.AcceptanceTests.RateLimiting;
89
using Ocelot.Cache;
910
using Ocelot.Configuration.File;
1011
using Ocelot.DependencyInjection;
@@ -14,7 +15,7 @@
1415

1516
namespace Ocelot.AcceptanceTests.ServiceDiscovery
1617
{
17-
public sealed class ConsulConfigurationInConsulTests : Steps, IDisposable
18+
public sealed class ConsulConfigurationInConsulTests : RateLimitingSteps, IDisposable
1819
{
1920
private IWebHost _builder;
2021
private IWebHost _fakeConsulBuilder;

test/Ocelot.AcceptanceTests/Steps.cs

-11
Original file line numberDiff line numberDiff line change
@@ -733,17 +733,6 @@ public static async Task WhenIDoActionMultipleTimes(int times, Func<int, Task> a
733733
await action.Invoke(i);
734734
}
735735

736-
public async Task WhenIGetUrlOnTheApiGatewayMultipleTimesForRateLimit(string url, int times)
737-
{
738-
for (var i = 0; i < times; i++)
739-
{
740-
const string clientId = "ocelotclient1";
741-
var request = new HttpRequestMessage(new HttpMethod("GET"), url);
742-
request.Headers.Add("ClientId", clientId);
743-
_response = await _ocelotClient.SendAsync(request);
744-
}
745-
}
746-
747736
public async Task WhenIGetUrlOnTheApiGateway(string url, string requestId)
748737
{
749738
_ocelotClient.DefaultRequestHeaders.TryAddWithoutValidation(RequestIdKey, requestId);

test/Ocelot.UnitTests/RateLimiting/RateLimitingMiddlewareTests.cs

+3-1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public class RateLimitingMiddlewareTests : UnitTest
2121
private readonly IRateLimitStorage _storage;
2222
private readonly Mock<IOcelotLoggerFactory> _loggerFactory;
2323
private readonly Mock<IOcelotLogger> _logger;
24+
private readonly Mock<IHttpContextAccessor> _contextAccessor;
2425
private readonly RateLimitingMiddleware _middleware;
2526
private readonly RequestDelegate _next;
2627
private readonly IRateLimiting _rateLimiting;
@@ -37,7 +38,8 @@ public RateLimitingMiddlewareTests()
3738
_loggerFactory.Setup(x => x.CreateLogger<RateLimitingMiddleware>()).Returns(_logger.Object);
3839
_next = context => Task.CompletedTask;
3940
_rateLimiting = new _RateLimiting_(_storage);
40-
_middleware = new RateLimitingMiddleware(_next, _loggerFactory.Object, _rateLimiting);
41+
_contextAccessor = new Mock<IHttpContextAccessor>();
42+
_middleware = new RateLimitingMiddleware(_next, _loggerFactory.Object, _rateLimiting, _contextAccessor.Object);
4143
_downstreamResponses = new();
4244
}
4345

0 commit comments

Comments
 (0)