Skip to content

Commit 11e6272

Browse files
authored
Attribute request failure blame before determining a destination's health (#1648)
1 parent 6a572f4 commit 11e6272

File tree

2 files changed

+197
-2
lines changed

2 files changed

+197
-2
lines changed

src/ReverseProxy/Health/TransportFailureRateHealthPolicy.cs

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,7 @@ public TransportFailureRateHealthPolicy(
4646

4747
public void RequestProxied(HttpContext context, ClusterState cluster, DestinationState destination)
4848
{
49-
var error = context.Features.Get<IForwarderErrorFeature>();
50-
var newHealth = EvaluateProxiedRequest(cluster, destination, error is not null);
49+
var newHealth = EvaluateProxiedRequest(cluster, destination, DetermineIfDestinationFailed(context));
5150
var clusterReactivationPeriod = cluster.Model.Config.HealthCheck?.Passive?.ReactivationPeriod ?? _defaultReactivationPeriod;
5251
// Avoid reactivating until the history has expired so that it does not affect future health assessments.
5352
var reactivationPeriod = clusterReactivationPeriod >= _policyOptions.DetectionWindowSize ? clusterReactivationPeriod : _policyOptions.DetectionWindowSize;
@@ -75,6 +74,30 @@ private static bool TryParse(string stringValue, out double parsedValue)
7574
return double.TryParse(stringValue, NumberStyles.Float, CultureInfo.InvariantCulture, out parsedValue);
7675
}
7776

77+
private static bool DetermineIfDestinationFailed(HttpContext context)
78+
{
79+
var errorFeature = context.Features.Get<IForwarderErrorFeature>();
80+
if (errorFeature is null)
81+
{
82+
return false;
83+
}
84+
85+
if (context.RequestAborted.IsCancellationRequested)
86+
{
87+
// The client disconnected/canceled the request - the failure may not be the destination's fault
88+
return false;
89+
}
90+
91+
var error = errorFeature.Error;
92+
93+
return error == ForwarderError.Request
94+
|| error == ForwarderError.RequestTimedOut
95+
|| error == ForwarderError.RequestBodyDestination
96+
|| error == ForwarderError.ResponseBodyDestination
97+
|| error == ForwarderError.UpgradeRequestDestination
98+
|| error == ForwarderError.UpgradeResponseDestination;
99+
}
100+
78101
private class ProxiedRequestHistory
79102
{
80103
private const long RecordWindowSize = 1000;
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System;
5+
using System.IO;
6+
using System.Net;
7+
using System.Net.Http;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
using Microsoft.Extensions.DependencyInjection;
11+
using Xunit;
12+
using Yarp.ReverseProxy.Common;
13+
using Yarp.ReverseProxy.Configuration;
14+
using Yarp.ReverseProxy.Forwarder;
15+
16+
namespace Yarp.ReverseProxy;
17+
18+
public class PassiveHealthCheckTests
19+
{
20+
private sealed class MockHttpClientFactory : IForwarderHttpClientFactory
21+
{
22+
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _sendAsync;
23+
24+
public MockHttpClientFactory(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> sendAsync)
25+
{
26+
_sendAsync = sendAsync;
27+
}
28+
29+
public HttpMessageInvoker CreateClient(ForwarderHttpClientContext context)
30+
{
31+
return new HttpMessageInvoker(new MockHandler(_sendAsync));
32+
}
33+
34+
private sealed class MockHandler : HttpMessageHandler
35+
{
36+
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _sendAsync;
37+
38+
public MockHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> sendAsync)
39+
{
40+
_sendAsync = sendAsync;
41+
}
42+
43+
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
44+
{
45+
return await _sendAsync(request, cancellationToken);
46+
}
47+
}
48+
}
49+
50+
[Fact]
51+
public async Task PassiveHealthChecksEnabled_MultipleDestinationFailures_ProxyReturnsServiceUnavailable()
52+
{
53+
var destinationReached = false;
54+
55+
var test = new TestEnvironment(
56+
context =>
57+
{
58+
destinationReached = true;
59+
throw new InvalidOperationException();
60+
},
61+
proxyBuilder => proxyBuilder.Services.AddSingleton<IForwarderHttpClientFactory>(new MockHttpClientFactory((_, _) => throw new IOException())),
62+
proxyApp => { },
63+
configTransformer: (c, r) =>
64+
{
65+
c = c with
66+
{
67+
HealthCheck = new HealthCheckConfig
68+
{
69+
Passive = new PassiveHealthCheckConfig
70+
{
71+
Enabled = true
72+
}
73+
}
74+
};
75+
76+
return (c, r);
77+
});
78+
79+
await test.Invoke(async uri =>
80+
{
81+
using var client = new HttpClient();
82+
for (var i = 0; i < 42; i++)
83+
{
84+
using var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, uri));
85+
86+
Assert.Equal(i < 10 ? HttpStatusCode.BadGateway : HttpStatusCode.ServiceUnavailable, response.StatusCode);
87+
}
88+
});
89+
90+
Assert.False(destinationReached);
91+
}
92+
93+
[Fact]
94+
public async Task PassiveHealthChecksEnabled_IncompleteClientRequests_ProxyHealthIsUnaffected()
95+
{
96+
var destinationReached = false;
97+
98+
var shouldThrow = true;
99+
var requestStartedTcs = new TaskCompletionSource<byte>(TaskCreationOptions.RunContinuationsAsynchronously);
100+
101+
var proxySendAsync = async (HttpRequestMessage request, CancellationToken ct) =>
102+
{
103+
requestStartedTcs.SetResult(0);
104+
105+
if (shouldThrow)
106+
{
107+
await Task.Delay(-1, ct);
108+
109+
throw new OperationCanceledException(ct);
110+
}
111+
else
112+
{
113+
return new HttpResponseMessage((HttpStatusCode)418)
114+
{
115+
Content = new StringContent("Hello world")
116+
};
117+
}
118+
};
119+
120+
var test = new TestEnvironment(
121+
context =>
122+
{
123+
destinationReached = true;
124+
throw new InvalidOperationException();
125+
},
126+
proxyBuilder => proxyBuilder.Services.AddSingleton<IForwarderHttpClientFactory>(new MockHttpClientFactory(proxySendAsync)),
127+
proxyApp => { },
128+
configTransformer: (c, r) =>
129+
{
130+
c = c with
131+
{
132+
HealthCheck = new HealthCheckConfig
133+
{
134+
Passive = new PassiveHealthCheckConfig
135+
{
136+
Enabled = true
137+
}
138+
}
139+
};
140+
141+
return (c, r);
142+
});
143+
144+
await test.Invoke(async uri =>
145+
{
146+
using var client = new HttpClient();
147+
for (var i = 0; i < 42; i++)
148+
{
149+
using var cts = new CancellationTokenSource();
150+
_ = requestStartedTcs.Task.ContinueWith(_ => cts.Cancel());
151+
152+
try
153+
{
154+
await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, uri), cts.Token);
155+
Assert.True(false);
156+
}
157+
catch { }
158+
159+
requestStartedTcs = new TaskCompletionSource<byte>(TaskCreationOptions.RunContinuationsAsynchronously);
160+
}
161+
162+
shouldThrow = false;
163+
164+
using var response = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, uri));
165+
166+
Assert.Equal(418, (int)response.StatusCode);
167+
Assert.Equal("Hello world", await response.Content.ReadAsStringAsync());
168+
});
169+
170+
Assert.False(destinationReached);
171+
}
172+
}

0 commit comments

Comments
 (0)