Skip to content

Commit da9d6fa

Browse files
jlukawskaJolanta Łukawskaraman-m
authored
#1305 Populate RateLimiting headers in the original HttpContext response accessed via IHttpContextAccessor (#1307)
* set rate limiting headers on the proper httpcontext * fix Retry-After header * merge fix * refactor of ClientRateLimitTests * merge fix * Fix build after rebasing * EOL: test/Ocelot.AcceptanceTests/Steps.cs * Add `RateLimitingSteps` * code review by @raman-m * Inject IHttpContextAccessor, not IServiceProvider * Ocelot's rate-limiting headers have become legacy * Headers definition life hack * A good StackOverflow link --------- Co-authored-by: Jolanta Łukawska <jolanta.lukawska@outlook.com> Co-authored-by: Raman Maksimchuk <dotnet044@gmail.com>
1 parent d310508 commit da9d6fa

File tree

8 files changed

+135
-42
lines changed

8 files changed

+135
-42
lines changed

src/Ocelot/Configuration/RateLimitOptions.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ public RateLimitOptions(bool enableRateLimiting, string clientIdHeader, Func<Lis
3232
/// Gets the list of white listed clients.
3333
/// </summary>
3434
/// <value>
35-
/// A <see cref="List{T}"/> collection with white listed clients.
35+
/// A <see cref="List{T}"/> (where T is <see cref="string"/>) collection with white listed clients.
3636
/// </value>
3737
public List<string> ClientWhitelist => _getClientWhitelist();
3838

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

8282
/// <summary>
83-
/// Disables X-Rate-Limit and Rety-After headers.
83+
/// Disables <c>X-Rate-Limit</c> and <c>Retry-After</c> headers.
8484
/// </summary>
8585
/// <value>
86-
/// A boolean value for disabling X-Rate-Limit and Rety-After headers.
86+
/// A boolean value for disabling <c>X-Rate-Limit</c> and <c>Retry-After</c> headers.
8787
/// </value>
8888
public bool DisableRateLimitHeaders { get; }
8989
}

src/Ocelot/RateLimiting/Middleware/RateLimitingMiddleware.cs

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
using Microsoft.AspNetCore.Http;
2+
using Microsoft.Net.Http.Headers;
23
using Ocelot.Configuration;
34
using Ocelot.Logging;
45
using Ocelot.Middleware;
@@ -10,15 +11,18 @@ public class RateLimitingMiddleware : OcelotMiddleware
1011
{
1112
private readonly RequestDelegate _next;
1213
private readonly IRateLimiting _limiter;
14+
private readonly IHttpContextAccessor _contextAccessor;
1315

1416
public RateLimitingMiddleware(
1517
RequestDelegate next,
1618
IOcelotLoggerFactory factory,
17-
IRateLimiting limiter)
19+
IRateLimiting limiter,
20+
IHttpContextAccessor contextAccessor)
1821
: base(factory.CreateLogger<RateLimitingMiddleware>())
1922
{
2023
_next = next;
2124
_limiter = limiter;
25+
_contextAccessor = contextAccessor;
2226
}
2327

2428
public async Task Invoke(HttpContext httpContext)
@@ -68,11 +72,15 @@ public async Task Invoke(HttpContext httpContext)
6872
}
6973
}
7074

71-
//set X-Rate-Limit headers for the longest period
75+
// Set X-Rate-Limit headers for the longest period
7276
if (!options.DisableRateLimitHeaders)
7377
{
74-
var headers = _limiter.GetHeaders(httpContext, identity, options);
75-
httpContext.Response.OnStarting(SetRateLimitHeaders, state: headers);
78+
var originalContext = _contextAccessor?.HttpContext;
79+
if (originalContext != null)
80+
{
81+
var headers = _limiter.GetHeaders(originalContext, identity, options);
82+
originalContext.Response.OnStarting(SetRateLimitHeaders, state: headers);
83+
}
7684
}
7785

7886
await _next.Invoke(httpContext);
@@ -93,15 +101,8 @@ public virtual ClientRequestIdentity SetIdentity(HttpContext httpContext, RateLi
93101
);
94102
}
95103

96-
public bool IsWhitelisted(ClientRequestIdentity requestIdentity, RateLimitOptions option)
97-
{
98-
if (option.ClientWhitelist.Contains(requestIdentity.ClientId))
99-
{
100-
return true;
101-
}
102-
103-
return false;
104-
}
104+
public static bool IsWhitelisted(ClientRequestIdentity requestIdentity, RateLimitOptions option)
105+
=> option.ClientWhitelist.Contains(requestIdentity.ClientId);
105106

106107
public virtual void LogBlockedRequest(HttpContext httpContext, ClientRequestIdentity identity, RateLimitCounter counter, RateLimitRule rule, DownstreamRoute downstreamRoute)
107108
{
@@ -112,14 +113,15 @@ public virtual void LogBlockedRequest(HttpContext httpContext, ClientRequestIden
112113
public virtual DownstreamResponse ReturnQuotaExceededResponse(HttpContext httpContext, RateLimitOptions option, string retryAfter)
113114
{
114115
var message = GetResponseMessage(option);
115-
116-
var http = new HttpResponseMessage((HttpStatusCode)option.HttpStatusCode);
117-
118-
http.Content = new StringContent(message);
116+
var http = new HttpResponseMessage((HttpStatusCode)option.HttpStatusCode)
117+
{
118+
Content = new StringContent(message),
119+
};
119120

120121
if (!option.DisableRateLimitHeaders)
121122
{
122-
http.Headers.TryAddWithoutValidation("Retry-After", retryAfter); // in seconds, not date string
123+
http.Headers.TryAddWithoutValidation(HeaderNames.RetryAfter, retryAfter); // in seconds, not date string
124+
httpContext.Response.Headers[HeaderNames.RetryAfter] = retryAfter;
123125
}
124126

125127
return new DownstreamResponse(http);
@@ -133,14 +135,17 @@ private static string GetResponseMessage(RateLimitOptions option)
133135
return message;
134136
}
135137

136-
private static Task SetRateLimitHeaders(object rateLimitHeaders)
138+
/// <summary>TODO: Produced Ocelot's headers don't follow industry standards.</summary>
139+
/// <remarks>More details in <see cref="RateLimitingHeaders"/> docs.</remarks>
140+
/// <param name="state">Captured state as a <see cref="RateLimitHeaders"/> object.</param>
141+
/// <returns>The <see cref="Task.CompletedTask"/> object.</returns>
142+
private static Task SetRateLimitHeaders(object state)
137143
{
138-
var headers = (RateLimitHeaders)rateLimitHeaders;
139-
140-
headers.Context.Response.Headers["X-Rate-Limit-Limit"] = headers.Limit;
141-
headers.Context.Response.Headers["X-Rate-Limit-Remaining"] = headers.Remaining;
142-
headers.Context.Response.Headers["X-Rate-Limit-Reset"] = headers.Reset;
143-
144+
var limitHeaders = (RateLimitHeaders)state;
145+
var headers = limitHeaders.Context.Response.Headers;
146+
headers[RateLimitingHeaders.X_Rate_Limit_Limit] = limitHeaders.Limit;
147+
headers[RateLimitingHeaders.X_Rate_Limit_Remaining] = limitHeaders.Remaining;
148+
headers[RateLimitingHeaders.X_Rate_Limit_Reset] = limitHeaders.Reset;
144149
return Task.CompletedTask;
145150
}
146151
}
Lines changed: 32 additions & 0 deletions
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

Lines changed: 50 additions & 1 deletion
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
{
Lines changed: 15 additions & 0 deletions
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/ServiceDiscovery/ConsulConfigurationInConsulTests.cs

Lines changed: 2 additions & 1 deletion
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

Lines changed: 0 additions & 11 deletions
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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ public class RateLimitingMiddlewareTests : UnitTest
1818
private readonly IRateLimitStorage _storage;
1919
private readonly Mock<IOcelotLoggerFactory> _loggerFactory;
2020
private readonly Mock<IOcelotLogger> _logger;
21+
private readonly Mock<IHttpContextAccessor> _contextAccessor;
2122
private readonly RateLimitingMiddleware _middleware;
2223
private readonly RequestDelegate _next;
2324
private readonly IRateLimiting _rateLimiting;
@@ -34,7 +35,8 @@ public RateLimitingMiddlewareTests()
3435
_loggerFactory.Setup(x => x.CreateLogger<RateLimitingMiddleware>()).Returns(_logger.Object);
3536
_next = context => Task.CompletedTask;
3637
_rateLimiting = new _RateLimiting_(_storage);
37-
_middleware = new RateLimitingMiddleware(_next, _loggerFactory.Object, _rateLimiting);
38+
_contextAccessor = new Mock<IHttpContextAccessor>();
39+
_middleware = new RateLimitingMiddleware(_next, _loggerFactory.Object, _rateLimiting, _contextAccessor.Object);
3840
_downstreamResponses = new();
3941
}
4042

0 commit comments

Comments
 (0)