From d5969b31c888e60308294ab53282b265cd6959b3 Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 3 Oct 2026 13:56:46 +0200 Subject: [PATCH 1/2] Resolve @dynamic Retry-After for any status code in GenericRandomErrorPlugin Previously @dynamic was only resolved for 429 responses, so 503/529 errors sent the literal '@dynamic' value. Closes dotnet/dev-proxy#1905 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../BehaviorPluginsIntegrationTests.cs | 40 +++++++++++++++++++ .../Behavior/GenericRandomErrorPlugin.cs | 9 +++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs b/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs index be477243..3e412d56 100644 --- a/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs +++ b/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs @@ -64,6 +64,46 @@ public async Task GenericRandomError_Rate100_OverridesOriginWithConfiguredError( Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); } + [Fact] + public async Task GenericRandomError_DynamicRetryAfterOnNon429_IsResolved() + { + await using var origin = await FakeOrigin.StartAsync(); + var urls = KestrelProxyHarness.BuildUrlsToWatch(origin.Host); + + var config = PluginConfig.FromJson($$""" + { + "rate": 100, + "errors": [ + { + "request": { "url": "http://{{origin.Host}}/*" }, + "responses": [ + { + "statusCode": 503, + "headers": [ { "name": "Retry-After", "value": "@dynamic=7" } ] + } + ] + } + ] + } + """); + var plugin = new GenericRandomErrorPlugin( + SharedHttpClient, + NullLogger.Instance, + urls, + ProxyConfig, + config); + + 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.ServiceUnavailable, response.StatusCode); + Assert.True(response.Headers.TryGetValues("Retry-After", out var values)); + Assert.Equal("7", Assert.Single(values)); + } + [Fact] public async Task Latency_AddsConfiguredDelayBeforeForwarding() { diff --git a/DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs b/DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs index 8c6f8d7c..d0ff8a8d 100644 --- a/DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs +++ b/DevProxy.Plugins/Behavior/GenericRandomErrorPlugin.cs @@ -219,10 +219,11 @@ private void UpdateProxyResponse(ProxyRequestArgs e, GenericErrorResponseRespons headers.AddRange(error.Headers); } - if (error.StatusCode == (int)HttpStatusCode.TooManyRequests && - error.Headers is not null) + // Retry-After can be sent with any status code (eg. 429, 503, 529), + // so resolve @dynamic regardless of the status code + if (error.Headers is not null) { - var retryAfterHeader = error.Headers.FirstOrDefault(h => h.Name is "Retry-After" or "retry-after"); + var retryAfterHeader = error.Headers.FirstOrDefault(h => string.Equals(h.Name, "Retry-After", StringComparison.OrdinalIgnoreCase)); if (retryAfterHeader?.Value is not null && retryAfterHeader.Value.StartsWith("@dynamic", StringComparison.OrdinalIgnoreCase)) { // Parse @dynamic or @dynamic=value syntax @@ -257,7 +258,7 @@ private void UpdateProxyResponse(ProxyRequestArgs e, GenericErrorResponseRespons var throttleKey = BuildThrottleKey(request); throttledRequests?.Add(new(throttleKey, (req, key) => ShouldThrottle(req, key, retryAfterInSeconds), retryAfterDate)); // replace the header with the @dynamic value with the actual value - var h = headers.First(h => h.Name is "Retry-After" or "retry-after"); + var h = headers.First(h => string.Equals(h.Name, "Retry-After", StringComparison.OrdinalIgnoreCase)); _ = headers.Remove(h); headers.Add(new("Retry-After", retryAfterInSeconds.ToString(CultureInfo.InvariantCulture))); } From adc32dbd7926d214cd330c736e77390834615bfb Mon Sep 17 00:00:00 2001 From: waldekmastykarz Date: Sat, 3 Oct 2026 20:25:21 +0200 Subject: [PATCH 2/2] Strengthen dynamic Retry-After non-429 test Use mixed-case header name to cover case-insensitive lookup and verify RetryAfterPlugin throttles an immediate retry. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../BehaviorPluginsIntegrationTests.cs | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs b/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs index 3e412d56..bf476053 100644 --- a/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs +++ b/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs @@ -79,7 +79,7 @@ public async Task GenericRandomError_DynamicRetryAfterOnNon429_IsResolved() "responses": [ { "statusCode": 503, - "headers": [ { "name": "Retry-After", "value": "@dynamic=7" } ] + "headers": [ { "name": "RETRY-after", "value": "@dynamic=7" } ] } ] } @@ -92,16 +92,23 @@ public async Task GenericRandomError_DynamicRetryAfterOnNon429_IsResolved() urls, ProxyConfig, config); + var retryAfter = new RetryAfterPlugin( + NullLogger.Instance, + urls); await using var proxy = await KestrelProxyHarness.StartAsync( - origin.Host, [plugin]); + origin.Host, [retryAfter, plugin]); using var client = proxy.CreateHttpClient(); using var response = await client.GetAsync(new Uri($"http://{origin.Host}/get")); + // an immediate retry must be throttled by RetryAfterPlugin, proving the + // non-429 error registered the request for throttling + using var retry = await client.GetAsync(new Uri($"http://{origin.Host}/get")); Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode); Assert.True(response.Headers.TryGetValues("Retry-After", out var values)); Assert.Equal("7", Assert.Single(values)); + Assert.Equal(HttpStatusCode.TooManyRequests, retry.StatusCode); } [Fact]