Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,107 @@ public async Task GenericRandomError_DynamicRetryAfterOnNon429_IsResolved()
Assert.Equal(HttpStatusCode.TooManyRequests, retry.StatusCode);
}

[Fact]
public async Task GenericRandomError_BodyWithoutContentType_DefaultsToApplicationJson()
{
await using var origin = await FakeOrigin.StartAsync();
var dir = Directory.CreateTempSubdirectory("devproxy-errors-");
try
{
await File.WriteAllTextAsync(
Path.Combine(dir.FullName, "errors.json"),
$$"""
{
"errors": [
{
"request": { "url": "http://{{origin.Host}}/*" },
"responses": [ { "statusCode": 500, "body": { "error": "boom" } } ]
}
]
}
""");
var proxyConfig = new TestProxyConfiguration
{
ConfigFile = Path.Combine(dir.FullName, "devproxyrc.json"),
};
var plugin = new GenericRandomErrorPlugin(
SharedHttpClient,
NullLogger<GenericRandomErrorPlugin>.Instance,
KestrelProxyHarness.BuildUrlsToWatch(origin.Host),
proxyConfig,
PluginConfig.FromJson("""{ "rate": 100 }"""));

using var host = new PluginTestHost(proxyConfig);
await plugin.InitializeAsync(host.CreateInitArgs(), CancellationToken.None);

await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host, [plugin]);
using var client = proxy.CreateHttpClient();

using var response = await client.GetAsync(new Uri($"http://{origin.Host}/get"));

Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode);
Assert.Equal("application/json", response.Content.Headers.ContentType?.MediaType);
}
finally
{
dir.Delete(recursive: true);
}
}

[Fact]
public async Task GenericRandomError_ExplicitContentType_IsPreserved()
{
await using var origin = await FakeOrigin.StartAsync();
var dir = Directory.CreateTempSubdirectory("devproxy-errors-");
try
{
await File.WriteAllTextAsync(
Path.Combine(dir.FullName, "errors.json"),
$$"""
{
"errors": [
{
"request": { "url": "http://{{origin.Host}}/*" },
"responses": [
{
"statusCode": 500,
"headers": [ { "name": "Content-Type", "value": "text/plain" } ],
"body": "boom"
}
]
}
]
}
""");
var proxyConfig = new TestProxyConfiguration
{
ConfigFile = Path.Combine(dir.FullName, "devproxyrc.json"),
};
var plugin = new GenericRandomErrorPlugin(
SharedHttpClient,
NullLogger<GenericRandomErrorPlugin>.Instance,
KestrelProxyHarness.BuildUrlsToWatch(origin.Host),
proxyConfig,
PluginConfig.FromJson("""{ "rate": 100 }"""));

using var host = new PluginTestHost(proxyConfig);
await plugin.InitializeAsync(host.CreateInitArgs(), CancellationToken.None);

await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host, [plugin]);
using var client = proxy.CreateHttpClient();

using var response = await client.GetAsync(new Uri($"http://{origin.Host}/get"));

Assert.Equal("text/plain", response.Content.Headers.ContentType?.MediaType);
Assert.True(response.Content.Headers.TryGetValues("Content-Type", out var values));
_ = Assert.Single(values);
}
finally
{
dir.Delete(recursive: true);
}
}

[Fact]
public async Task Latency_AddsConfiguredDelayBeforeForwarding()
{
Expand Down Expand Up @@ -268,6 +369,50 @@ public async Task RateLimiting_ThrottlesOnceResourcesAreExhausted()
Assert.Equal(HttpStatusCode.TooManyRequests, second.StatusCode);
}

[Fact]
public async Task RateLimiting_CustomResponseWithBody_DefaultsToApplicationJson()
{
await using var origin = await FakeOrigin.StartAsync();
var dir = Directory.CreateTempSubdirectory("devproxy-ratelimit-");
try
{
await File.WriteAllTextAsync(
Path.Combine(dir.FullName, "rate-limit-response.json"),
"""
{ "statusCode": 403, "body": { "error": "quota exceeded" } }
""");
var proxyConfig = new TestProxyConfiguration
{
ConfigFile = Path.Combine(dir.FullName, "devproxyrc.json"),
};
var plugin = new RateLimitingPlugin(
SharedHttpClient,
NullLogger<RateLimitingPlugin>.Instance,
KestrelProxyHarness.BuildUrlsToWatch(origin.Host),
proxyConfig,
PluginConfig.FromJson("""
{ "rateLimit": 2, "costPerRequest": 2, "resetTimeWindowSeconds": 300, "whenLimitExceeded": "Custom" }
"""));

using var host = new PluginTestHost(proxyConfig);
await plugin.InitializeAsync(host.CreateInitArgs(), CancellationToken.None);

await using var proxy = await KestrelProxyHarness.StartAsync(origin.Host, [plugin]);
using var client = proxy.CreateHttpClient();

using var first = await client.GetAsync(new Uri($"http://{origin.Host}/get"));
using var limited = await client.GetAsync(new Uri($"http://{origin.Host}/get"));

Assert.Equal(HttpStatusCode.OK, first.StatusCode);
Assert.Equal(HttpStatusCode.Forbidden, limited.StatusCode);
Assert.Equal("application/json", limited.Content.Headers.ContentType?.MediaType);
}
finally
{
dir.Delete(recursive: true);
}
}

[Fact]
public async Task RateLimitingPlusRetryAfter_ThrottledResponseCarriesRetryAfter()
{
Expand Down
7 changes: 7 additions & 0 deletions DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,13 @@ private void UpdateProxyResponse(ProxyRequestArgs e, GenericErrorResponseRespons
}
}

// default the content type to application/json unless set in the error response
if (error.Body is not null &&
!headers.Any(h => string.Equals(h.Name, "Content-Type", StringComparison.OrdinalIgnoreCase)))
{
headers.Add(new("Content-Type", "application/json"));
}

var statusCode = (HttpStatusCode)(error.StatusCode ?? 400);
var body = error.Body is null ? string.Empty : JsonSerializer.Serialize(error.Body, ProxyUtils.JsonSerializerOptions);
// we get a JSON string so need to start with the opening quote
Expand Down
7 changes: 7 additions & 0 deletions DevProxy.Plugins/Behavior/RateLimitingPlugin.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,13 @@ public override Task BeforeRequestAsync(ProxyRequestArgs e, CancellationToken ca
_ = headersList.Remove(retryAfterHeader);
}

// default the content type to application/json unless set in the custom response
if (Configuration.CustomResponse.Body is not null &&
!headersList.Any(h => h.Name.Equals("Content-Type", StringComparison.OrdinalIgnoreCase)))
{
headersList.Add(new("Content-Type", "application/json"));
}

var headers = headersList.ToArray();

// allow custom throttling response
Expand Down
Loading