Skip to content

Commit 56fa099

Browse files
Adds generic chaos plugin (#186)
1 parent da5e752 commit 56fa099

File tree

5 files changed

+244
-26
lines changed

5 files changed

+244
-26
lines changed
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using System.Text.Json.Serialization;
2+
3+
namespace Microsoft.Graph.DeveloperProxy.Plugins.RandomErrors;
4+
5+
public class GenericErrorResponse {
6+
[JsonPropertyName("statusCode")]
7+
public int StatusCode { get; set; }
8+
[JsonPropertyName("headers")]
9+
public Dictionary<string, string>? Headers { get; set; }
10+
[JsonPropertyName("body")]
11+
public dynamic? Body { get; set; }
12+
[JsonPropertyName("addDynamicRetryAfter")]
13+
public bool? AddDynamicRetryAfter { get; set; } = false;
14+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using Microsoft.Graph.DeveloperProxy.Abstractions;
5+
using System.Text.Json;
6+
7+
namespace Microsoft.Graph.DeveloperProxy.Plugins.RandomErrors;
8+
9+
internal class GenericErrorResponsesLoader : IDisposable {
10+
private readonly ILogger _logger;
11+
private readonly GenericRandomErrorConfiguration _configuration;
12+
13+
public GenericErrorResponsesLoader(ILogger logger, GenericRandomErrorConfiguration configuration) {
14+
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
15+
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
16+
}
17+
18+
private string _errorsFile => Path.Combine(Directory.GetCurrentDirectory(), _configuration.ErrorsFile ?? "");
19+
private FileSystemWatcher? _watcher;
20+
21+
public void LoadResponses() {
22+
if (!File.Exists(_errorsFile)) {
23+
_logger.LogWarn($"File {_configuration.ErrorsFile} not found in the current directory. No error responses will be loaded");
24+
_configuration.Responses = Array.Empty<GenericErrorResponse>();
25+
return;
26+
}
27+
28+
try {
29+
var responsesString = File.ReadAllText(_errorsFile);
30+
var responsesConfig = JsonSerializer.Deserialize<GenericRandomErrorConfiguration>(responsesString);
31+
IEnumerable<GenericErrorResponse>? configResponses = responsesConfig?.Responses;
32+
if (configResponses is not null) {
33+
_configuration.Responses = configResponses;
34+
_logger.LogInfo($"Error responses for {configResponses.Count()} url patterns loaded from from {_configuration.ErrorsFile}");
35+
}
36+
}
37+
catch (Exception ex) {
38+
_logger.LogError($"An error has occurred while reading {_configuration.ErrorsFile}:");
39+
_logger.LogError(ex.Message);
40+
}
41+
}
42+
43+
public void InitResponsesWatcher() {
44+
if (_watcher is not null) {
45+
return;
46+
}
47+
48+
string path = Path.GetDirectoryName(_errorsFile) ?? throw new InvalidOperationException($"{_errorsFile} is an invalid path");
49+
_watcher = new FileSystemWatcher(path);
50+
_watcher.NotifyFilter = NotifyFilters.CreationTime
51+
| NotifyFilters.FileName
52+
| NotifyFilters.LastWrite
53+
| NotifyFilters.Size;
54+
_watcher.Filter = Path.GetFileName(_errorsFile);
55+
_watcher.Changed += ResponsesFile_Changed;
56+
_watcher.Created += ResponsesFile_Changed;
57+
_watcher.Deleted += ResponsesFile_Changed;
58+
_watcher.Renamed += ResponsesFile_Changed;
59+
_watcher.EnableRaisingEvents = true;
60+
61+
LoadResponses();
62+
}
63+
64+
private void ResponsesFile_Changed(object sender, FileSystemEventArgs e) {
65+
LoadResponses();
66+
}
67+
68+
public void Dispose() {
69+
_watcher?.Dispose();
70+
}
71+
}
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using Microsoft.Extensions.Configuration;
5+
using Microsoft.Graph.DeveloperProxy.Abstractions;
6+
using System.Net;
7+
using System.Text.Json;
8+
using System.Text.Json.Serialization;
9+
using System.Text.RegularExpressions;
10+
using Titanium.Web.Proxy.EventArguments;
11+
using Titanium.Web.Proxy.Http;
12+
using Titanium.Web.Proxy.Models;
13+
14+
namespace Microsoft.Graph.DeveloperProxy.Plugins.RandomErrors;
15+
internal enum GenericRandomErrorFailMode {
16+
Throttled,
17+
Random,
18+
PassThru
19+
}
20+
21+
public class GenericRandomErrorConfiguration {
22+
public int Rate { get; set; } = 0;
23+
public string? ErrorsFile { get; set; }
24+
[JsonPropertyName("responses")]
25+
public IEnumerable<GenericErrorResponse> Responses { get; set; } = Array.Empty<GenericErrorResponse>();
26+
}
27+
28+
public class GenericRandomErrorPlugin : BaseProxyPlugin {
29+
private readonly GenericRandomErrorConfiguration _configuration = new();
30+
private GenericErrorResponsesLoader? _loader = null;
31+
32+
public override string Name => nameof(GenericRandomErrorPlugin);
33+
34+
private const int retryAfterInSeconds = 5;
35+
private readonly Dictionary<string, DateTime> _throttledRequests;
36+
private readonly Random _random;
37+
38+
public GenericRandomErrorPlugin() {
39+
_random = new Random();
40+
_throttledRequests = new Dictionary<string, DateTime>();
41+
}
42+
43+
// uses config to determine if a request should be failed
44+
private GenericRandomErrorFailMode ShouldFail(ProxyRequestArgs e) {
45+
var r = e.Session.HttpClient.Request;
46+
string key = BuildThrottleKey(r);
47+
if (_throttledRequests.TryGetValue(key, out DateTime retryAfterDate)) {
48+
if (retryAfterDate > DateTime.Now) {
49+
_logger?.LogRequest(new[] { $"Calling {r.Url} again before waiting for the Retry-After period.", "Request will be throttled" }, MessageType.Failed, new LoggingContext(e.Session));
50+
// update the retryAfterDate to extend the throttling window to ensure that brute forcing won't succeed.
51+
_throttledRequests[key] = retryAfterDate.AddSeconds(retryAfterInSeconds);
52+
return GenericRandomErrorFailMode.Throttled;
53+
}
54+
else {
55+
// clean up expired throttled request and ensure that this request is passed through.
56+
_throttledRequests.Remove(key);
57+
return GenericRandomErrorFailMode.PassThru;
58+
}
59+
}
60+
return _random.Next(1, 100) <= _configuration.Rate ? GenericRandomErrorFailMode.Random : GenericRandomErrorFailMode.PassThru;
61+
}
62+
63+
private void FailResponse(ProxyRequestArgs e, GenericRandomErrorFailMode failMode) {
64+
GenericErrorResponse error;
65+
if (failMode == GenericRandomErrorFailMode.Throttled) {
66+
error = new GenericErrorResponse {
67+
StatusCode = (int)HttpStatusCode.TooManyRequests,
68+
Headers = new Dictionary<string, string> {
69+
{ "Retry-After", retryAfterInSeconds.ToString() }
70+
}
71+
};
72+
}
73+
else {
74+
// pick a random error response for the current request
75+
error = _configuration.Responses.ElementAt(_random.Next(0, _configuration.Responses.Count()));
76+
}
77+
UpdateProxyResponse(e, error);
78+
}
79+
80+
private void UpdateProxyResponse(ProxyRequestArgs ev, GenericErrorResponse error) {
81+
SessionEventArgs session = ev.Session;
82+
Request request = session.HttpClient.Request;
83+
var headers = error.Headers is not null ?
84+
error.Headers.Select(h => new HttpHeader(h.Key, h.Value)).ToList() :
85+
new List<HttpHeader>();
86+
if (error.StatusCode == (int)HttpStatusCode.TooManyRequests &&
87+
error.AddDynamicRetryAfter.GetValueOrDefault(false)) {
88+
var retryAfterDate = DateTime.Now.AddSeconds(retryAfterInSeconds);
89+
_throttledRequests[BuildThrottleKey(request)] = retryAfterDate;
90+
headers.Add(new HttpHeader("Retry-After", retryAfterInSeconds.ToString()));
91+
}
92+
93+
string body = error.Body is null ? string.Empty : JsonSerializer.Serialize(error.Body);
94+
_logger?.LogRequest(new[] { $"{error.StatusCode} {((HttpStatusCode)error.StatusCode).ToString()}" }, MessageType.Chaos, new LoggingContext(ev.Session));
95+
session.GenericResponse(body ?? string.Empty, (HttpStatusCode)error.StatusCode, headers);
96+
}
97+
98+
private string BuildThrottleKey(Request r) => $"{r.Method}-{r.Url}";
99+
100+
public override void Register(IPluginEvents pluginEvents,
101+
IProxyContext context,
102+
ISet<Regex> urlsToWatch,
103+
IConfigurationSection? configSection = null) {
104+
base.Register(pluginEvents, context, urlsToWatch, configSection);
105+
106+
configSection?.Bind(_configuration);
107+
_loader = new GenericErrorResponsesLoader(_logger!, _configuration);
108+
109+
pluginEvents.Init += OnInit;
110+
pluginEvents.BeforeRequest += OnRequest;
111+
}
112+
113+
private void OnInit(object? sender, InitArgs e) {
114+
_loader?.InitResponsesWatcher();
115+
}
116+
117+
private void OnRequest(object? sender, ProxyRequestArgs e) {
118+
var session = e.Session;
119+
var state = e.ResponseState;
120+
if (!e.ResponseState.HasBeenSet
121+
&& _urlsToWatch is not null
122+
&& e.ShouldExecute(_urlsToWatch)) {
123+
var failMode = ShouldFail(e);
124+
125+
if (failMode == GenericRandomErrorFailMode.PassThru && _configuration.Rate != 100) {
126+
_logger?.LogRequest(new[] { "Passed through" }, MessageType.PassedThrough, new LoggingContext(e.Session));
127+
return;
128+
}
129+
FailResponse(e, failMode);
130+
state.HasBeenSet = true;
131+
}
132+
}
133+
}

msgraph-developer-proxy-plugins/RandomErrors/RandomErrorPlugin.cs renamed to msgraph-developer-proxy-plugins/RandomErrors/GraphRandomErrorPlugin.cs

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -14,23 +14,23 @@
1414
using Titanium.Web.Proxy.Models;
1515

1616
namespace Microsoft.Graph.DeveloperProxy.Plugins.RandomErrors;
17-
internal enum FailMode {
17+
internal enum GraphRandomErrorFailMode {
1818
Throttled,
1919
Random,
2020
PassThru
2121
}
2222

23-
public class RandomErrorConfiguration {
23+
public class GraphRandomErrorConfiguration {
2424
public int Rate { get; set; } = 0;
2525
public List<int> AllowedErrors { get; set; } = new();
2626
}
2727

28-
public class RandomErrorPlugin : BaseProxyPlugin {
28+
public class GraphRandomErrorPlugin : BaseProxyPlugin {
2929
private readonly Option<int?> _rate;
3030
private readonly Option<IEnumerable<int>> _allowedErrors;
31-
private readonly RandomErrorConfiguration _configuration = new();
31+
private readonly GraphRandomErrorConfiguration _configuration = new();
3232

33-
public override string Name => nameof(RandomErrorPlugin);
33+
public override string Name => nameof(GraphRandomErrorPlugin);
3434

3535
private const int retryAfterInSeconds = 5;
3636
private readonly Dictionary<string, HttpStatusCode[]> _methodStatusCode = new()
@@ -88,7 +88,7 @@ public class RandomErrorPlugin : BaseProxyPlugin {
8888
private readonly Dictionary<string, DateTime> _throttledRequests;
8989
private readonly Random _random;
9090

91-
public RandomErrorPlugin() {
91+
public GraphRandomErrorPlugin() {
9292
_rate = new Option<int?>("--failure-rate", "The percentage of requests to graph to respond with failures");
9393
_rate.AddAlias("-f");
9494
_rate.ArgumentHelpName = "failure rate";
@@ -108,28 +108,28 @@ public RandomErrorPlugin() {
108108
}
109109

110110
// uses config to determine if a request should be failed
111-
private FailMode ShouldFail(ProxyRequestArgs e) {
111+
private GraphRandomErrorFailMode ShouldFail(ProxyRequestArgs e) {
112112
var r = e.Session.HttpClient.Request;
113113
string key = BuildThrottleKey(r);
114114
if (_throttledRequests.TryGetValue(key, out DateTime retryAfterDate)) {
115115
if (retryAfterDate > DateTime.Now) {
116116
_logger?.LogRequest(new[] { $"Calling {r.Url} again before waiting for the Retry-After period.", "Request will be throttled" }, MessageType.Failed, new LoggingContext(e.Session));
117117
// update the retryAfterDate to extend the throttling window to ensure that brute forcing won't succeed.
118118
_throttledRequests[key] = retryAfterDate.AddSeconds(retryAfterInSeconds);
119-
return FailMode.Throttled;
119+
return GraphRandomErrorFailMode.Throttled;
120120
}
121121
else {
122122
// clean up expired throttled request and ensure that this request is passed through.
123123
_throttledRequests.Remove(key);
124-
return FailMode.PassThru;
124+
return GraphRandomErrorFailMode.PassThru;
125125
}
126126
}
127-
return _random.Next(1, 100) <= _configuration.Rate ? FailMode.Random : FailMode.PassThru;
127+
return _random.Next(1, 100) <= _configuration.Rate ? GraphRandomErrorFailMode.Random : GraphRandomErrorFailMode.PassThru;
128128
}
129129

130-
private void FailResponse(ProxyRequestArgs e, FailMode failMode) {
130+
private void FailResponse(ProxyRequestArgs e, GraphRandomErrorFailMode failMode) {
131131
HttpStatusCode errorStatus;
132-
if (failMode == FailMode.Throttled) {
132+
if (failMode == GraphRandomErrorFailMode.Throttled) {
133133
errorStatus = HttpStatusCode.TooManyRequests;
134134
}
135135
else {
@@ -152,11 +152,11 @@ private void UpdateProxyResponse(ProxyRequestArgs ev, HttpStatusCode errorStatus
152152
headers.Add(new HttpHeader("Retry-After", retryAfterInSeconds.ToString()));
153153
}
154154

155-
string body = JsonSerializer.Serialize(new ErrorResponseBody(
156-
new ErrorResponseError {
155+
string body = JsonSerializer.Serialize(new GraphErrorResponseBody(
156+
new GraphErrorResponseError {
157157
Code = new Regex("([A-Z])").Replace(errorStatus.ToString(), m => { return $" {m.Groups[1]}"; }).Trim(),
158158
Message = BuildApiErrorMessage(request),
159-
InnerError = new ErrorResponseInnerError {
159+
InnerError = new GraphErrorResponseInnerError {
160160
RequestId = requestId,
161161
Date = requestDate
162162
}
@@ -213,7 +213,7 @@ private void OnRequest(object? sender, ProxyRequestArgs e) {
213213
&& e.ShouldExecute(_urlsToWatch)) {
214214
var failMode = ShouldFail(e);
215215

216-
if (failMode == FailMode.PassThru && _configuration.Rate != 100) {
216+
if (failMode == GraphRandomErrorFailMode.PassThru && _configuration.Rate != 100) {
217217
return;
218218
}
219219
FailResponse(e, failMode);
@@ -223,25 +223,25 @@ private void OnRequest(object? sender, ProxyRequestArgs e) {
223223
}
224224

225225

226-
internal class ErrorResponseBody {
226+
internal class GraphErrorResponseBody {
227227
[JsonPropertyName("error")]
228-
public ErrorResponseError Error { get; set; }
228+
public GraphErrorResponseError Error { get; set; }
229229

230-
public ErrorResponseBody(ErrorResponseError error) {
230+
public GraphErrorResponseBody(GraphErrorResponseError error) {
231231
Error = error;
232232
}
233233
}
234234

235-
internal class ErrorResponseError {
235+
internal class GraphErrorResponseError {
236236
[JsonPropertyName("code")]
237237
public string Code { get; set; } = string.Empty;
238238
[JsonPropertyName("message")]
239239
public string Message { get; set; } = string.Empty;
240240
[JsonPropertyName("innerError")]
241-
public ErrorResponseInnerError? InnerError { get; set; }
241+
public GraphErrorResponseInnerError? InnerError { get; set; }
242242
}
243243

244-
internal class ErrorResponseInnerError {
244+
internal class GraphErrorResponseInnerError {
245245
[JsonPropertyName("request-id")]
246246
public string RequestId { get; set; } = string.Empty;
247247
[JsonPropertyName("date")]

msgraph-developer-proxy/appsettings.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,10 +48,10 @@
4848
"configSection": "mocksPlugin"
4949
},
5050
{
51-
"name": "RandomErrorPlugin",
51+
"name": "GraphRandomErrorPlugin",
5252
"disabled": false,
5353
"pluginPath": "GraphProxyPlugins\\msgraph-developer-proxy-plugins.dll",
54-
"configSection": "randomErrorsPlugin"
54+
"configSection": "graphRandomErrorsPlugin"
5555
},
5656
{
5757
"name": "ODataPagingGuidancePlugin",
@@ -75,9 +75,9 @@
7575
"https://*.sharepoint-df.*/*_vti_bin/*"
7676
],
7777
"mocksPlugin": {
78-
"mocksFile": "responses.json"
78+
"mocksFile": "responses.json"
7979
},
80-
"randomErrorsPlugin": {
80+
"graphRandomErrorsPlugin": {
8181
"rate": 50,
8282
"allowedErrors": [ 429, 500, 502, 503, 504, 507 ]
8383
},

0 commit comments

Comments
 (0)