diff --git a/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs b/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs index be477243..bf476053 100644 --- a/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs +++ b/DevProxy.Integration.Tests/BehaviorPluginsIntegrationTests.cs @@ -64,6 +64,53 @@ 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); + var retryAfter = new RetryAfterPlugin( + NullLogger.Instance, + urls); + + await using var proxy = await KestrelProxyHarness.StartAsync( + 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] 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))); }