Skip to content

Commit b0c5412

Browse files
Centralizes retry-after handling. Closes #239 (#257)
* Centralizes retry-after handling. Closes #239 Improves throttling * Fixes issues with rate limiting and retry-after
1 parent 30b131e commit b0c5412

File tree

11 files changed

+310
-193
lines changed

11 files changed

+310
-193
lines changed

msgraph-developer-proxy-abstractions/PluginEvents.cs

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.CommandLine;
55
using System.CommandLine.Invocation;
66
using Titanium.Web.Proxy.EventArguments;
7+
using Titanium.Web.Proxy.Http;
78

89
namespace Microsoft.Graph.DeveloperProxy.Abstractions;
910

@@ -12,11 +13,52 @@ public interface IProxyContext {
1213
ILogger Logger { get; }
1314
}
1415

16+
public class ThrottlerInfo {
17+
/// <summary>
18+
/// Throttling key used to identify which requests should be throttled.
19+
/// Can be set to a hostname, full URL or a custom string value, that
20+
/// represents for example a portion of the API
21+
/// </summary>
22+
public string ThrottlingKey { get; private set; }
23+
/// <summary>
24+
/// Function responsible for matching the request to the throttling key.
25+
/// Takes as arguments:
26+
/// - intercepted request
27+
/// - the throttling key
28+
/// Returns an instance of ThrottlingInfo that contains information
29+
/// whether the request should be throttled or not.
30+
/// </summary>
31+
public Func<Request, string, ThrottlingInfo> ShouldThrottle { get; private set; }
32+
/// <summary>
33+
/// Time when the throttling window will be reset
34+
/// </summary>
35+
public DateTime ResetTime { get; set; }
36+
37+
public ThrottlerInfo(string throttlingKey, Func<Request, string, ThrottlingInfo> shouldThrottle, DateTime resetTime) {
38+
ThrottlingKey = throttlingKey ?? throw new ArgumentNullException(nameof(throttlingKey));
39+
ShouldThrottle = shouldThrottle ?? throw new ArgumentNullException(nameof(shouldThrottle));
40+
ResetTime = resetTime;
41+
}
42+
}
43+
44+
public class ThrottlingInfo {
45+
public int ThrottleForSeconds { get; set; }
46+
public string RetryAfterHeaderName { get; set; }
47+
48+
public ThrottlingInfo(int throttleForSeconds, string retryAfterHeaderName) {
49+
ThrottleForSeconds = throttleForSeconds;
50+
RetryAfterHeaderName = retryAfterHeaderName ?? throw new ArgumentNullException(nameof(retryAfterHeaderName));
51+
}
52+
}
53+
1554
public class ProxyHttpEventArgsBase {
16-
internal ProxyHttpEventArgsBase(SessionEventArgs session) =>
55+
internal ProxyHttpEventArgsBase(SessionEventArgs session, IList<ThrottlerInfo> throttledRequests) {
1756
Session = session ?? throw new ArgumentNullException(nameof(session));
57+
ThrottledRequests = throttledRequests ?? throw new ArgumentNullException(nameof(throttledRequests));
58+
}
1859

1960
public SessionEventArgs Session { get; }
61+
public IList<ThrottlerInfo> ThrottledRequests { get; }
2062

2163
public bool HasRequestUrlMatch(ISet<UrlToWatch> watchedUrls) {
2264
var match = watchedUrls.FirstOrDefault(r => r.Url.IsMatch(Session.HttpClient.Request.RequestUri.AbsoluteUri));
@@ -25,7 +67,7 @@ public bool HasRequestUrlMatch(ISet<UrlToWatch> watchedUrls) {
2567
}
2668

2769
public class ProxyRequestArgs : ProxyHttpEventArgsBase {
28-
public ProxyRequestArgs(SessionEventArgs session, ResponseState responseState) : base(session) {
70+
public ProxyRequestArgs(SessionEventArgs session, IList<ThrottlerInfo> throttledRequests, ResponseState responseState) : base(session, throttledRequests) {
2971
ResponseState = responseState ?? throw new ArgumentNullException(nameof(responseState));
3072
}
3173
public ResponseState ResponseState { get; }
@@ -36,7 +78,7 @@ public bool ShouldExecute(ISet<UrlToWatch> watchedUrls) =>
3678
}
3779

3880
public class ProxyResponseArgs : ProxyHttpEventArgsBase {
39-
public ProxyResponseArgs(SessionEventArgs session, ResponseState responseState) : base(session) {
81+
public ProxyResponseArgs(SessionEventArgs session, IList<ThrottlerInfo> throttledRequests, ResponseState responseState) : base(session, throttledRequests) {
4082
ResponseState = responseState ?? throw new ArgumentNullException(nameof(responseState));
4183
}
4284
public ResponseState ResponseState { get; }

msgraph-developer-proxy-plugins/Behavior/RateLimitingPlugin.cs

Lines changed: 73 additions & 100 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
using Microsoft.Graph.DeveloperProxy.Abstractions;
66
using System.Net;
77
using System.Text.Json;
8-
using System.Text.Json.Serialization;
98
using System.Text.RegularExpressions;
109
using Titanium.Web.Proxy.Http;
1110
using Titanium.Web.Proxy.Models;
@@ -27,46 +26,14 @@ public class RateLimitConfiguration {
2726
public class RateLimitingPlugin : BaseProxyPlugin {
2827
public override string Name => nameof(RateLimitingPlugin);
2928
private readonly RateLimitConfiguration _configuration = new();
30-
private readonly Dictionary<string, DateTime> _throttledRequests = new();
3129
// initial values so that we know when we intercept the
3230
// first request and can set the initial values
3331
private int _resourcesRemaining = -1;
3432
private DateTime _resetTime = DateTime.MinValue;
3533

36-
private bool ShouldForceThrottle(ProxyRequestArgs e) {
37-
var r = e.Session.HttpClient.Request;
38-
string key = BuildThrottleKey(r);
39-
if (_throttledRequests.TryGetValue(key, out DateTime retryAfterDate)) {
40-
if (retryAfterDate > DateTime.Now) {
41-
_logger?.LogRequest(new[] { $"Calling {r.Url} again before waiting for the Retry-After period.", "Request will be throttled" }, MessageType.Failed, new LoggingContext(e.Session));
42-
// update the retryAfterDate to extend the throttling window to ensure that brute forcing won't succeed.
43-
_throttledRequests[key] = retryAfterDate.AddSeconds(_configuration.RetryAfterSeconds);
44-
return true;
45-
}
46-
else {
47-
// clean up expired throttled request and ensure that this request is passed through.
48-
_throttledRequests.Remove(key);
49-
return false;
50-
}
51-
}
52-
53-
return false;
54-
}
55-
56-
private void ForceThrottleResponse(ProxyRequestArgs e) => UpdateProxyResponse(e, HttpStatusCode.TooManyRequests);
57-
58-
private bool ShouldThrottle(ProxyRequestArgs e) {
59-
if (_resourcesRemaining > 0) {
60-
return false;
61-
}
62-
63-
var r = e.Session.HttpClient.Request;
64-
string key = BuildThrottleKey(r);
65-
66-
_logger?.LogRequest(new[] { $"Exceeded resource limit when calling {r.Url}.", "Request will be throttled" }, MessageType.Failed, new LoggingContext(e.Session));
67-
// update the retryAfterDate to extend the throttling window to ensure that brute forcing won't succeed.
68-
_throttledRequests[key] = DateTime.Now.AddSeconds(_configuration.RetryAfterSeconds);
69-
return true;
34+
private ThrottlingInfo ShouldThrottle(Request request, string throttlingKey) {
35+
var throttleKeyForRequest = BuildThrottleKey(request);
36+
return new ThrottlingInfo(throttleKeyForRequest == throttlingKey ? _configuration.RetryAfterSeconds : 0, _configuration.HeaderRetryAfter);
7037
}
7138

7239
private void ThrottleResponse(ProxyRequestArgs e) => UpdateProxyResponse(e, HttpStatusCode.TooManyRequests);
@@ -75,24 +42,31 @@ private void UpdateProxyResponse(ProxyHttpEventArgsBase e, HttpStatusCode errorS
7542
var headers = new List<HttpHeader>();
7643
var body = string.Empty;
7744
var request = e.Session.HttpClient.Request;
45+
var response = e.Session.HttpClient.Response;
7846

79-
// override the response body and headers for the error response
80-
if (errorStatus != HttpStatusCode.OK &&
81-
ProxyUtils.IsGraphRequest(request)) {
82-
string requestId = Guid.NewGuid().ToString();
83-
string requestDate = DateTime.Now.ToString();
84-
headers.AddRange(ProxyUtils.BuildGraphResponseHeaders(request, requestId, requestDate));
85-
86-
body = JsonSerializer.Serialize(new GraphErrorResponseBody(
87-
new GraphErrorResponseError {
88-
Code = new Regex("([A-Z])").Replace(errorStatus.ToString(), m => { return $" {m.Groups[1]}"; }).Trim(),
89-
Message = BuildApiErrorMessage(request),
90-
InnerError = new GraphErrorResponseInnerError {
91-
RequestId = requestId,
92-
Date = requestDate
93-
}
94-
})
95-
);
47+
// resources exceeded
48+
if (errorStatus == HttpStatusCode.TooManyRequests) {
49+
if (ProxyUtils.IsGraphRequest(request)) {
50+
string requestId = Guid.NewGuid().ToString();
51+
string requestDate = DateTime.Now.ToString();
52+
headers.AddRange(ProxyUtils.BuildGraphResponseHeaders(request, requestId, requestDate));
53+
54+
body = JsonSerializer.Serialize(new GraphErrorResponseBody(
55+
new GraphErrorResponseError {
56+
Code = new Regex("([A-Z])").Replace(errorStatus.ToString(), m => { return $" {m.Groups[1]}"; }).Trim(),
57+
Message = BuildApiErrorMessage(request),
58+
InnerError = new GraphErrorResponseInnerError {
59+
RequestId = requestId,
60+
Date = requestDate
61+
}
62+
})
63+
);
64+
}
65+
66+
headers.Add(new HttpHeader(_configuration.HeaderRetryAfter, _configuration.RetryAfterSeconds.ToString()));
67+
68+
e.Session.GenericResponse(body ?? string.Empty, errorStatus, headers);
69+
return;
9670
}
9771

9872
// add rate limiting headers if reached the threshold percentage
@@ -102,24 +76,51 @@ private void UpdateProxyResponse(ProxyHttpEventArgsBase e, HttpStatusCode errorS
10276
new HttpHeader(_configuration.HeaderRemaining, _resourcesRemaining.ToString()),
10377
new HttpHeader(_configuration.HeaderReset, (_resetTime - DateTime.Now).TotalSeconds.ToString("N0")) // drop decimals
10478
});
105-
}
10679

107-
// send an error response if we are (forced) throttling
108-
if (errorStatus == HttpStatusCode.TooManyRequests) {
109-
headers.Add(new HttpHeader(_configuration.HeaderRetryAfter, _configuration.RetryAfterSeconds.ToString()));
80+
// make rate limiting information available for CORS requests
81+
if (request.Headers.FirstOrDefault((h) => h.Name.Equals("Origin", StringComparison.OrdinalIgnoreCase)) is not null) {
82+
if (!response.Headers.HeaderExists("Access-Control-Allow-Origin")) {
83+
headers.Add(new HttpHeader("Access-Control-Allow-Origin", "*"));
84+
}
85+
var exposeHeadersHeader = response.Headers.FirstOrDefault((h) => h.Name.Equals("Access-Control-Expose-Headers", StringComparison.OrdinalIgnoreCase));
86+
var headerValue = "";
87+
if (exposeHeadersHeader is null) {
88+
headerValue = $"{_configuration.HeaderLimit}, {_configuration.HeaderRemaining}, {_configuration.HeaderReset}, {_configuration.HeaderRetryAfter}";
89+
}
90+
else {
91+
headerValue = exposeHeadersHeader.Value;
92+
if (!headerValue.Contains(_configuration.HeaderLimit)) {
93+
headerValue += $", {_configuration.HeaderLimit}";
94+
}
95+
if (!headerValue.Contains(_configuration.HeaderRemaining)) {
96+
headerValue += $", {_configuration.HeaderRemaining}";
97+
}
98+
if (!headerValue.Contains(_configuration.HeaderReset)) {
99+
headerValue += $", {_configuration.HeaderReset}";
100+
}
101+
if (!headerValue.Contains(_configuration.HeaderRetryAfter)) {
102+
headerValue += $", {_configuration.HeaderRetryAfter}";
103+
}
104+
response.Headers.RemoveHeader("Access-Control-Expose-Headers");
105+
}
110106

111-
e.Session.GenericResponse(body ?? string.Empty, errorStatus, headers);
112-
return;
107+
headers.Add(new HttpHeader("Access-Control-Expose-Headers", headerValue));
108+
}
113109
}
114110

115-
if (errorStatus == HttpStatusCode.OK) {
116-
// add headers to the original API response
117-
e.Session.HttpClient.Response.Headers.AddHeaders(headers);
118-
}
111+
// add headers to the original API response
112+
e.Session.HttpClient.Response.Headers.AddHeaders(headers);
119113
}
120114
private static string BuildApiErrorMessage(Request r) => $"Some error was generated by the proxy. {(ProxyUtils.IsGraphRequest(r) ? ProxyUtils.IsSdkRequest(r) ? "" : String.Join(' ', MessageUtils.BuildUseSdkForErrorsMessage(r)) : "")}";
121115

122-
private string BuildThrottleKey(Request r) => $"{r.Method}-{r.Url}";
116+
private string BuildThrottleKey(Request r) {
117+
if (ProxyUtils.IsGraphRequest(r)) {
118+
return GraphUtils.BuildThrottleKey(r);
119+
}
120+
else {
121+
return r.RequestUri.Host;
122+
}
123+
}
123124

124125
public override void Register(IPluginEvents pluginEvents,
125126
IProxyContext context,
@@ -134,8 +135,6 @@ public override void Register(IPluginEvents pluginEvents,
134135

135136
// add rate limiting headers to the response from the API
136137
private async Task OnResponse(object? sender, ProxyResponseArgs e) {
137-
var session = e.Session;
138-
var state = e.ResponseState;
139138
if (_urlsToWatch is null ||
140139
!e.HasRequestUrlMatch(_urlsToWatch)) {
141140
return;
@@ -169,44 +168,18 @@ _urlsToWatch is null ||
169168

170169
// subtract the cost of the request
171170
_resourcesRemaining -= _configuration.CostPerRequest;
172-
// avoid communicating negative values
173171
if (_resourcesRemaining < 0) {
174-
_resourcesRemaining = 0;
175-
}
172+
var request = e.Session.HttpClient.Request;
173+
174+
_logger?.LogRequest(new[] { $"Exceeded resource limit when calling {request.Url}.", "Request will be throttled" }, MessageType.Failed, new LoggingContext(e.Session));
175+
e.ThrottledRequests.Add(new ThrottlerInfo(
176+
BuildThrottleKey(request),
177+
ShouldThrottle,
178+
DateTime.Now.AddSeconds(_configuration.RetryAfterSeconds)
179+
));
176180

177-
if (ShouldForceThrottle(e)) {
178-
ForceThrottleResponse(e);
179-
state.HasBeenSet = true;
180-
}
181-
else if (ShouldThrottle(e)) {
182181
ThrottleResponse(e);
183182
state.HasBeenSet = true;
184183
}
185184
}
186185
}
187-
188-
189-
internal class GraphErrorResponseBody {
190-
[JsonPropertyName("error")]
191-
public GraphErrorResponseError Error { get; set; }
192-
193-
public GraphErrorResponseBody(GraphErrorResponseError error) {
194-
Error = error;
195-
}
196-
}
197-
198-
internal class GraphErrorResponseError {
199-
[JsonPropertyName("code")]
200-
public string Code { get; set; } = string.Empty;
201-
[JsonPropertyName("message")]
202-
public string Message { get; set; } = string.Empty;
203-
[JsonPropertyName("innerError")]
204-
public GraphErrorResponseInnerError? InnerError { get; set; }
205-
}
206-
207-
internal class GraphErrorResponseInnerError {
208-
[JsonPropertyName("request-id")]
209-
public string RequestId { get; set; } = string.Empty;
210-
[JsonPropertyName("date")]
211-
public string Date { get; set; } = string.Empty;
212-
}

0 commit comments

Comments
 (0)