From 0042d556982bc4dd24e1c685d099541f77ac58d9 Mon Sep 17 00:00:00 2001 From: Waldek Mastykarz Date: Wed, 21 Oct 2026 15:55:39 +0200 Subject: [PATCH] Default Content-Type to application/json for generic error and custom rate-limit responses GenericRandomErrorPlugin and RateLimitingPlugin (custom response) now add Content-Type: application/json when the response has a body and no Content-Type header is configured, matching MockResponsePlugin. Closes #1971 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: b01cdbfd-df85-41d9-925d-3fe22389de95 --- .../BehaviorPluginsIntegrationTests.cs | 145 ++++++++++++++++++ .../Behavior/GenericRandomErrorPlugin.cs | 7 + .../Behavior/RateLimitingPlugin.cs | 7 + 3 files changed, 159 insertions(+) diff --git a/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs b/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs index 1e4fa357a..7ffe39114 100644 --- a/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs +++ b/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs @@ -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.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.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() { @@ -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.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() { diff --git a/DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs b/DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs index e488c6276..0de848428 100644 --- a/DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs +++ b/DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs @@ -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 diff --git a/DevProxy.Plugins/Behavior/RateLimitingPlugin.cs b/DevProxy.Plugins/Behavior/RateLimitingPlugin.cs index 46cd78ac5..3236d2991 100644 --- a/DevProxy.Plugins/Behavior/RateLimitingPlugin.cs +++ b/DevProxy.Plugins/Behavior/RateLimitingPlugin.cs @@ -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