Timeout integration (#2307)
This commit is contained in:
Родитель
01ed48e19d
Коммит
48e3f7eaaa
|
@ -0,0 +1,75 @@
|
|||
# Request Timeouts
|
||||
|
||||
## Introduction
|
||||
|
||||
.NET 8 introduced the [Request Timeouts Middleware](https://learn.microsoft.com/aspnet/core/performance/timeouts) to enable configuring request timeouts globally as well as per endpoint. This functionality is also available in YARP 2.1 when running on .NET 8.
|
||||
|
||||
## Defaults
|
||||
Requests do not have any timeouts by default, other than the [Activity Timeout](http-client-config.md#HttpRequest) used to clean up idle requests. A default policy specified in [RequestTimeoutOptions](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.http.timeouts.requesttimeoutoptions) will apply to proxied requests as well.
|
||||
|
||||
## Configuration
|
||||
Timeouts and Timeout Policies can be specified per route via [RouteConfig](xref:Yarp.ReverseProxy.Configuration.RouteConfig) and can be bound from the `Routes` sections of the config file. As with other route properties, this can be modified and reloaded without restarting the proxy. Policy names are case insensitive.
|
||||
|
||||
Timeouts are specified in a TimeSpan HH:MM:SS format. Specifying both Timeout and TimeoutPolicy on the same route is invalid and will cause the configuration to be rejected.
|
||||
|
||||
Example:
|
||||
```JSON
|
||||
{
|
||||
"ReverseProxy": {
|
||||
"Routes": {
|
||||
"route1" : {
|
||||
"ClusterId": "cluster1",
|
||||
"TimeoutPolicy": "customPolicy",
|
||||
"Match": {
|
||||
"Hosts": [ "localhost" ]
|
||||
},
|
||||
}
|
||||
"route2" : {
|
||||
"ClusterId": "cluster1",
|
||||
"Timeout": "00:01:00",
|
||||
"Match": {
|
||||
"Hosts": [ "localhost2" ]
|
||||
},
|
||||
}
|
||||
},
|
||||
"Clusters": {
|
||||
"cluster1": {
|
||||
"Destinations": {
|
||||
"cluster1/destination1": {
|
||||
"Address": "https://localhost:10001/"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Timeout policies and the default policy can be configured in the service collection and the middleware can be added as follows:
|
||||
```csharp
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddReverseProxy()
|
||||
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
|
||||
|
||||
builder.Services.AddRequestTimeouts(options =>
|
||||
{
|
||||
options.AddPolicy("customPolicy", TimeSpan.FromSeconds(20));
|
||||
});
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
app.UseRequestTimeouts();
|
||||
|
||||
app.MapReverseProxy();
|
||||
|
||||
app.Run();
|
||||
```
|
||||
|
||||
### Disable timeouts
|
||||
|
||||
Specifying the value `disable` in a route's `TimeoutPolicy` parameter means the request timeout middleware will not apply timeouts to this route.
|
||||
|
||||
### WebSockets
|
||||
|
||||
Request timeouts are disabled after the initial WebSocket handshake.
|
|
@ -42,6 +42,8 @@
|
|||
href: grpc.md
|
||||
- name: WebSockets and SPDY
|
||||
href: websockets.md
|
||||
- name: Timeouts
|
||||
href: timeouts.md
|
||||
- name: Service Fabric Integration
|
||||
href: service-fabric-int.md
|
||||
- name: Http.sys Delegation
|
||||
|
|
|
@ -21,3 +21,7 @@ The incoming and outgoing protocol versions do not need to match. The incoming W
|
|||
WebSockets require different HTTP headers for HTTP/2 so YARP will add and remove these headers as needed when adapting between the different versions.
|
||||
|
||||
After the initial handshake WebSockets function the same way over both HTTP versions.
|
||||
|
||||
## Timeout
|
||||
|
||||
[Http Request Timeouts](https://learn.microsoft.com/aspnet/core/performance/timeouts) (.NET 8+) can apply timeouts to all requests by default or by policy. These timeouts will be disabled after a WebSocket handshake. They will still apply to gRPC requests. For additional configuration see [Timeouts](timeouts.md).
|
||||
|
|
|
@ -2,9 +2,16 @@ var builder = WebApplication.CreateBuilder(args);
|
|||
|
||||
builder.Services.AddReverseProxy()
|
||||
.LoadFromConfig(builder.Configuration.GetSection("ReverseProxy"));
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
builder.Services.AddRequestTimeouts(options =>
|
||||
{
|
||||
options.AddPolicy("customPolicy", TimeSpan.FromSeconds(20));
|
||||
});
|
||||
#endif
|
||||
var app = builder.Build();
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
app.UseRequestTimeouts();
|
||||
#endif
|
||||
app.MapReverseProxy();
|
||||
|
||||
app.Run();
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Yarp.ReverseProxy.Configuration;
|
||||
|
||||
|
@ -18,6 +19,8 @@ internal sealed class YarpIngressOptions
|
|||
public HttpClientConfig HttpClientConfig { get; set; }
|
||||
public string LoadBalancingPolicy { get; set; }
|
||||
public string CorsPolicy { get; set; }
|
||||
public string TimeoutPolicy { get; set; }
|
||||
public TimeSpan? Timeout { get; set; }
|
||||
public HealthCheckConfig HealthCheck { get; set; }
|
||||
public Dictionary<string, string> RouteMetadata { get; set; }
|
||||
public List<RouteHeader> RouteHeaders { get; set; }
|
||||
|
|
|
@ -142,6 +142,10 @@ internal static class YarpParser
|
|||
AuthorizationPolicy = ingressContext.Options.AuthorizationPolicy,
|
||||
#if NET7_0_OR_GREATER
|
||||
RateLimiterPolicy = ingressContext.Options.RateLimiterPolicy,
|
||||
#endif
|
||||
#if NET8_0_OR_GREATER
|
||||
Timeout = ingressContext.Options.Timeout,
|
||||
TimeoutPolicy = ingressContext.Options.TimeoutPolicy,
|
||||
#endif
|
||||
CorsPolicy = ingressContext.Options.CorsPolicy,
|
||||
Metadata = ingressContext.Options.RouteMetadata,
|
||||
|
|
|
@ -149,6 +149,10 @@ internal sealed class ConfigurationConfigProvider : IProxyConfigProvider, IDispo
|
|||
AuthorizationPolicy = section[nameof(RouteConfig.AuthorizationPolicy)],
|
||||
#if NET7_0_OR_GREATER
|
||||
RateLimiterPolicy = section[nameof(RouteConfig.RateLimiterPolicy)],
|
||||
#endif
|
||||
#if NET8_0_OR_GREATER
|
||||
TimeoutPolicy = section[nameof(RouteConfig.TimeoutPolicy)],
|
||||
Timeout = section.ReadTimeSpan(nameof(RouteConfig.Timeout)),
|
||||
#endif
|
||||
CorsPolicy = section[nameof(RouteConfig.CorsPolicy)],
|
||||
Metadata = section.GetSection(nameof(RouteConfig.Metadata)).ReadStringDictionary(),
|
||||
|
|
|
@ -11,8 +11,14 @@ using System.Threading.Tasks;
|
|||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
#if NET8_0_OR_GREATER
|
||||
using Microsoft.AspNetCore.Http.Timeouts;
|
||||
#endif
|
||||
using Microsoft.AspNetCore.Routing.Patterns;
|
||||
using Microsoft.Extensions.Logging;
|
||||
#if NET8_0_OR_GREATER
|
||||
using Microsoft.Extensions.Options;
|
||||
#endif
|
||||
using Yarp.ReverseProxy.Health;
|
||||
using Yarp.ReverseProxy.LoadBalancing;
|
||||
using Yarp.ReverseProxy.SessionAffinity;
|
||||
|
@ -32,6 +38,9 @@ internal sealed class ConfigValidator : IConfigValidator
|
|||
private readonly IAuthorizationPolicyProvider _authorizationPolicyProvider;
|
||||
private readonly IYarpRateLimiterPolicyProvider _rateLimiterPolicyProvider;
|
||||
private readonly ICorsPolicyProvider _corsPolicyProvider;
|
||||
#if NET8_0_OR_GREATER
|
||||
private readonly IOptionsMonitor<RequestTimeoutOptions> _timeoutOptions;
|
||||
#endif
|
||||
private readonly FrozenDictionary<string, ILoadBalancingPolicy> _loadBalancingPolicies;
|
||||
private readonly FrozenDictionary<string, IAffinityFailurePolicy> _affinityFailurePolicies;
|
||||
private readonly FrozenDictionary<string, IAvailableDestinationsPolicy> _availableDestinationsPolicies;
|
||||
|
@ -39,11 +48,13 @@ internal sealed class ConfigValidator : IConfigValidator
|
|||
private readonly FrozenDictionary<string, IPassiveHealthCheckPolicy> _passiveHealthCheckPolicies;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
|
||||
public ConfigValidator(ITransformBuilder transformBuilder,
|
||||
IAuthorizationPolicyProvider authorizationPolicyProvider,
|
||||
IYarpRateLimiterPolicyProvider rateLimiterPolicyProvider,
|
||||
ICorsPolicyProvider corsPolicyProvider,
|
||||
#if NET8_0_OR_GREATER
|
||||
IOptionsMonitor<RequestTimeoutOptions> timeoutOptions,
|
||||
#endif
|
||||
IEnumerable<ILoadBalancingPolicy> loadBalancingPolicies,
|
||||
IEnumerable<IAffinityFailurePolicy> affinityFailurePolicies,
|
||||
IEnumerable<IAvailableDestinationsPolicy> availableDestinationsPolicies,
|
||||
|
@ -55,6 +66,9 @@ internal sealed class ConfigValidator : IConfigValidator
|
|||
_authorizationPolicyProvider = authorizationPolicyProvider ?? throw new ArgumentNullException(nameof(authorizationPolicyProvider));
|
||||
_rateLimiterPolicyProvider = rateLimiterPolicyProvider ?? throw new ArgumentNullException(nameof(rateLimiterPolicyProvider));
|
||||
_corsPolicyProvider = corsPolicyProvider ?? throw new ArgumentNullException(nameof(corsPolicyProvider));
|
||||
#if NET8_0_OR_GREATER
|
||||
_timeoutOptions = timeoutOptions ?? throw new ArgumentNullException(nameof(timeoutOptions));
|
||||
#endif
|
||||
_loadBalancingPolicies = loadBalancingPolicies?.ToDictionaryByUniqueId(p => p.Name) ?? throw new ArgumentNullException(nameof(loadBalancingPolicies));
|
||||
_affinityFailurePolicies = affinityFailurePolicies?.ToDictionaryByUniqueId(p => p.Name) ?? throw new ArgumentNullException(nameof(affinityFailurePolicies));
|
||||
_availableDestinationsPolicies = availableDestinationsPolicies?.ToDictionaryByUniqueId(p => p.Name) ?? throw new ArgumentNullException(nameof(availableDestinationsPolicies));
|
||||
|
@ -78,6 +92,9 @@ internal sealed class ConfigValidator : IConfigValidator
|
|||
await ValidateAuthorizationPolicyAsync(errors, route.AuthorizationPolicy, route.RouteId);
|
||||
#if NET7_0_OR_GREATER
|
||||
await ValidateRateLimiterPolicyAsync(errors, route.RateLimiterPolicy, route.RouteId);
|
||||
#endif
|
||||
#if NET8_0_OR_GREATER
|
||||
ValidateTimeoutPolicy(errors, route.TimeoutPolicy, route.Timeout, route.RouteId);
|
||||
#endif
|
||||
await ValidateCorsPolicyAsync(errors, route.CorsPolicy, route.RouteId);
|
||||
|
||||
|
@ -294,7 +311,37 @@ internal sealed class ConfigValidator : IConfigValidator
|
|||
errors.Add(new ArgumentException($"Unable to retrieve the authorization policy '{authorizationPolicyName}' for route '{routeId}'.", ex));
|
||||
}
|
||||
}
|
||||
#if NET8_0_OR_GREATER
|
||||
private void ValidateTimeoutPolicy(IList<Exception> errors, string? timeoutPolicyName, TimeSpan? timeout, string routeId)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(timeoutPolicyName))
|
||||
{
|
||||
var policies = _timeoutOptions.CurrentValue.Policies;
|
||||
|
||||
if (string.Equals(TimeoutPolicyConstants.Disable, timeoutPolicyName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
if (policies.TryGetValue(timeoutPolicyName, out var _))
|
||||
{
|
||||
errors.Add(new ArgumentException($"The application has registered a timeout policy named '{timeoutPolicyName}' that conflicts with the reserved timeout policy name used on this route. The registered policy name needs to be changed for this route to function."));
|
||||
}
|
||||
}
|
||||
else if (!policies.TryGetValue(timeoutPolicyName, out var _))
|
||||
{
|
||||
errors.Add(new ArgumentException($"Timeout policy '{timeoutPolicyName}' not found for route '{routeId}'."));
|
||||
}
|
||||
|
||||
if (timeout.HasValue)
|
||||
{
|
||||
errors.Add(new ArgumentException($"Route '{routeId}' has both a Timeout '{timeout}' and TimeoutPolicy '{timeoutPolicyName}'."));
|
||||
}
|
||||
}
|
||||
|
||||
if (timeout.HasValue && timeout.Value.TotalMilliseconds <= 0)
|
||||
{
|
||||
errors.Add(new ArgumentException($"The Timeout value '{timeout.Value}' is invalid for route '{routeId}'. The Timeout must be greater than zero milliseconds."));
|
||||
}
|
||||
}
|
||||
#endif
|
||||
private async ValueTask ValidateRateLimiterPolicyAsync(IList<Exception> errors, string? rateLimiterPolicyName, string routeId)
|
||||
{
|
||||
if (string.IsNullOrEmpty(rateLimiterPolicyName))
|
||||
|
|
|
@ -51,6 +51,23 @@ public sealed record RouteConfig
|
|||
/// Set to "Default" or leave empty to use the global rate limits, if any.
|
||||
/// </summary>
|
||||
public string? RateLimiterPolicy { get; init; }
|
||||
#endif
|
||||
#if NET8_0_OR_GREATER
|
||||
/// <summary>
|
||||
/// The name of the TimeoutPolicy to apply to this route.
|
||||
/// Setting both Timeout and TimeoutPolicy is an error.
|
||||
/// If not set then only the system default will apply.
|
||||
/// Set to "Disable" to disable timeouts for this route.
|
||||
/// Set to "Default" or leave empty to use the system defaults, if any.
|
||||
/// </summary>
|
||||
public string? TimeoutPolicy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// The Timeout to apply to this route. This overrides any system defaults.
|
||||
/// Setting both Timeout and TimeoutPolicy is an error.
|
||||
/// Timeout granularity is limited to milliseconds.
|
||||
/// </summary>
|
||||
public TimeSpan? Timeout { get; init; }
|
||||
#endif
|
||||
/// <summary>
|
||||
/// The name of the CorsPolicy to apply to this route.
|
||||
|
@ -89,6 +106,10 @@ public sealed record RouteConfig
|
|||
&& string.Equals(AuthorizationPolicy, other.AuthorizationPolicy, StringComparison.OrdinalIgnoreCase)
|
||||
#if NET7_0_OR_GREATER
|
||||
&& string.Equals(RateLimiterPolicy, other.RateLimiterPolicy, StringComparison.OrdinalIgnoreCase)
|
||||
#endif
|
||||
#if NET8_0_OR_GREATER
|
||||
&& string.Equals(TimeoutPolicy, other.TimeoutPolicy, StringComparison.OrdinalIgnoreCase)
|
||||
&& Timeout == other.Timeout
|
||||
#endif
|
||||
&& string.Equals(CorsPolicy, other.CorsPolicy, StringComparison.OrdinalIgnoreCase)
|
||||
&& Match == other.Match
|
||||
|
@ -106,6 +127,10 @@ public sealed record RouteConfig
|
|||
hash.Add(AuthorizationPolicy?.GetHashCode(StringComparison.OrdinalIgnoreCase));
|
||||
#if NET7_0_OR_GREATER
|
||||
hash.Add(RateLimiterPolicy?.GetHashCode(StringComparison.OrdinalIgnoreCase));
|
||||
#endif
|
||||
#if NET8_0_OR_GREATER
|
||||
hash.Add(Timeout?.GetHashCode());
|
||||
hash.Add(TimeoutPolicy?.GetHashCode(StringComparison.OrdinalIgnoreCase));
|
||||
#endif
|
||||
hash.Add(CorsPolicy?.GetHashCode(StringComparison.OrdinalIgnoreCase));
|
||||
hash.Add(Match);
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
namespace Yarp.ReverseProxy.Configuration;
|
||||
|
||||
internal static class TimeoutPolicyConstants
|
||||
{
|
||||
internal const string Disable = "Disable";
|
||||
}
|
|
@ -12,6 +12,9 @@ using System.Threading;
|
|||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
#if NET8_0_OR_GREATER
|
||||
using Microsoft.AspNetCore.Http.Timeouts;
|
||||
#endif
|
||||
using Microsoft.AspNetCore.Server.Kestrel.Core.Features;
|
||||
using Microsoft.AspNetCore.WebUtilities;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
@ -719,6 +722,10 @@ internal sealed class HttpForwarder : IHttpForwarder
|
|||
Debug.Assert(upgradeFeature != null);
|
||||
upgradeResult = await upgradeFeature.UpgradeAsync();
|
||||
}
|
||||
#if NET8_0_OR_GREATER
|
||||
// Disable request timeout, if there is one, after the upgrade has been accepted
|
||||
context.Features.Get<IHttpRequestTimeoutFeature>()?.DisableTimeout();
|
||||
#endif
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
|
|
@ -5,7 +5,13 @@ using System;
|
|||
using System.Diagnostics;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
#if NET8_0_OR_GREATER
|
||||
using Microsoft.AspNetCore.Http.Timeouts;
|
||||
#endif
|
||||
using Microsoft.Extensions.Logging;
|
||||
#if NET8_0_OR_GREATER
|
||||
using Yarp.ReverseProxy.Configuration;
|
||||
#endif
|
||||
using Yarp.ReverseProxy.Utilities;
|
||||
|
||||
namespace Yarp.ReverseProxy.Model;
|
||||
|
@ -41,7 +47,19 @@ internal sealed class ProxyPipelineInitializerMiddleware
|
|||
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
#if NET8_0_OR_GREATER
|
||||
// There's no way to detect the presence of the timeout middleware before this, only the options.
|
||||
if (endpoint.Metadata.GetMetadata<RequestTimeoutAttribute>() != null
|
||||
&& context.Features.Get<IHttpRequestTimeoutFeature>() == null
|
||||
// The feature is skipped if the request is already canceled. We'll handle canceled requests later for consistency.
|
||||
&& !context.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
Log.TimeoutNotApplied(_logger, route.Config.RouteId);
|
||||
// Out of an abundance of caution, refuse the request rather than allowing it to proceed without the configured timeout.
|
||||
throw new InvalidOperationException($"The timeout was not applied for route '{route.Config.RouteId}', ensure `IApplicationBuilder.UseRequestTimeouts()`"
|
||||
+ " is called between `IApplicationBuilder.UseRouting()` and `IApplicationBuilder.UseEndpoints()`.");
|
||||
}
|
||||
#endif
|
||||
var destinationsState = cluster.DestinationsState;
|
||||
context.Features.Set<IReverseProxyFeature>(new ReverseProxyFeature
|
||||
{
|
||||
|
@ -80,9 +98,19 @@ internal sealed class ProxyPipelineInitializerMiddleware
|
|||
EventIds.NoClusterFound,
|
||||
"Route '{routeId}' has no cluster information.");
|
||||
|
||||
private static readonly Action<ILogger, string, Exception?> _timeoutNotApplied = LoggerMessage.Define<string>(
|
||||
LogLevel.Error,
|
||||
EventIds.TimeoutNotApplied,
|
||||
"The timeout was not applied for route '{routeId}', ensure `IApplicationBuilder.UseRequestTimeouts()` is called between `IApplicationBuilder.UseRouting()` and `IApplicationBuilder.UseEndpoints()`.");
|
||||
|
||||
public static void NoClusterFound(ILogger logger, string routeId)
|
||||
{
|
||||
_noClusterFound(logger, routeId, null);
|
||||
}
|
||||
|
||||
public static void TimeoutNotApplied(ILogger logger, string routeId)
|
||||
{
|
||||
_timeoutNotApplied(logger, routeId, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,6 +9,9 @@ using Microsoft.AspNetCore.Builder;
|
|||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
#if NET8_0_OR_GREATER
|
||||
using Microsoft.AspNetCore.Http.Timeouts;
|
||||
#endif
|
||||
#if NET7_0_OR_GREATER
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
#endif
|
||||
|
@ -18,6 +21,7 @@ using Yarp.ReverseProxy.Model;
|
|||
using CorsConstants = Yarp.ReverseProxy.Configuration.CorsConstants;
|
||||
using AuthorizationConstants = Yarp.ReverseProxy.Configuration.AuthorizationConstants;
|
||||
using RateLimitingConstants = Yarp.ReverseProxy.Configuration.RateLimitingConstants;
|
||||
using TimeoutPolicyConstants = Yarp.ReverseProxy.Configuration.TimeoutPolicyConstants;
|
||||
|
||||
namespace Yarp.ReverseProxy.Routing;
|
||||
|
||||
|
@ -26,6 +30,9 @@ internal sealed class ProxyEndpointFactory
|
|||
private static readonly IAuthorizeData _defaultAuthorization = new AuthorizeAttribute();
|
||||
#if NET7_0_OR_GREATER
|
||||
private static readonly DisableRateLimitingAttribute _disableRateLimit = new();
|
||||
#endif
|
||||
#if NET8_0_OR_GREATER
|
||||
private static readonly DisableRequestTimeoutAttribute _disableRequestTimeout = new();
|
||||
#endif
|
||||
private static readonly IEnableCorsAttribute _defaultCors = new EnableCorsAttribute();
|
||||
private static readonly IDisableCorsAttribute _disableCors = new DisableCorsAttribute();
|
||||
|
@ -131,6 +138,21 @@ internal sealed class ProxyEndpointFactory
|
|||
endpointBuilder.Metadata.Add(new EnableRateLimitingAttribute(config.RateLimiterPolicy));
|
||||
}
|
||||
#endif
|
||||
#if NET8_0_OR_GREATER
|
||||
if (string.Equals(TimeoutPolicyConstants.Disable, config.TimeoutPolicy, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
endpointBuilder.Metadata.Add(_disableRequestTimeout);
|
||||
}
|
||||
// The config validator shouldn't allow both TimeoutPolicy and Timeout, so we don't have to consider priority.
|
||||
else if (!string.IsNullOrEmpty(config.TimeoutPolicy))
|
||||
{
|
||||
endpointBuilder.Metadata.Add(new RequestTimeoutAttribute(config.TimeoutPolicy));
|
||||
}
|
||||
else if (config.Timeout.HasValue)
|
||||
{
|
||||
endpointBuilder.Metadata.Add(new RequestTimeoutAttribute((int)config.Timeout.Value.TotalMilliseconds));
|
||||
}
|
||||
#endif
|
||||
|
||||
for (var i = 0; i < conventions.Count; i++)
|
||||
{
|
||||
|
|
|
@ -67,4 +67,5 @@ internal static class EventIds
|
|||
public static readonly EventId RetryingWebSocketDowngradeNoConnect = new EventId(61, "RetryingWebSocketDowngradeNoConnect");
|
||||
public static readonly EventId RetryingWebSocketDowngradeNoHttp2 = new EventId(62, "RetryingWebSocketDowngradeNoHttp2");
|
||||
public static readonly EventId InvalidSecWebSocketKeyHeader = new EventId(63, "InvalidSecWebSocketKeyHeader");
|
||||
public static readonly EventId TimeoutNotApplied = new(64, nameof(TimeoutNotApplied));
|
||||
}
|
||||
|
|
|
@ -131,6 +131,10 @@ public class ConfigurationConfigProviderTests
|
|||
AuthorizationPolicy = "Default",
|
||||
#if NET7_0_OR_GREATER
|
||||
RateLimiterPolicy = "Default",
|
||||
#endif
|
||||
#if NET8_0_OR_GREATER
|
||||
TimeoutPolicy = "Default",
|
||||
Timeout = TimeSpan.Zero,
|
||||
#endif
|
||||
CorsPolicy = "Default",
|
||||
Order = -1,
|
||||
|
@ -344,6 +348,8 @@ public class ConfigurationConfigProviderTests
|
|||
""AuthorizationPolicy"": ""Default"",
|
||||
""RateLimiterPolicy"": ""Default"",
|
||||
""CorsPolicy"": ""Default"",
|
||||
""TimeoutPolicy"": ""Default"",
|
||||
""Timeout"": ""00:00:01"",
|
||||
""Metadata"": {
|
||||
""routeA-K1"": ""routeA-V1"",
|
||||
""routeA-K2"": ""routeA-V2""
|
||||
|
|
|
@ -607,7 +607,145 @@ public class ConfigValidatorTests
|
|||
Assert.NotEmpty(result);
|
||||
Assert.Contains(result, err => err.Message.Equals($"The application has registered an authorization policy named '{authorizationPolicy}' that conflicts with the reserved authorization policy name used on this route. The registered policy name needs to be changed for this route to function."));
|
||||
}
|
||||
#if NET8_0_OR_GREATER
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
[InlineData("disAble")]
|
||||
public async Task Accepts_ReservedTimeoutPolicy(string policy)
|
||||
{
|
||||
var route = new RouteConfig
|
||||
{
|
||||
RouteId = "route1",
|
||||
TimeoutPolicy = policy,
|
||||
Match = new RouteMatch
|
||||
{
|
||||
Hosts = new[] { "localhost" },
|
||||
},
|
||||
ClusterId = "cluster1",
|
||||
};
|
||||
|
||||
var services = CreateServices();
|
||||
var validator = services.GetRequiredService<IConfigValidator>();
|
||||
|
||||
var result = await validator.ValidateRouteAsync(route);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Accepts_CustomTimeoutPolicy()
|
||||
{
|
||||
var route = new RouteConfig
|
||||
{
|
||||
RouteId = "route1",
|
||||
TimeoutPolicy = "custom",
|
||||
Match = new RouteMatch
|
||||
{
|
||||
Hosts = new[] { "localhost" },
|
||||
},
|
||||
ClusterId = "cluster1",
|
||||
};
|
||||
|
||||
var services = CreateServices(services =>
|
||||
{
|
||||
services.AddRequestTimeouts(options =>
|
||||
{
|
||||
options.AddPolicy("custom", TimeSpan.FromSeconds(1));
|
||||
});
|
||||
});
|
||||
var validator = services.GetRequiredService<IConfigValidator>();
|
||||
|
||||
var result = await validator.ValidateRouteAsync(route);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Accepts_CustomTimeout()
|
||||
{
|
||||
var route = new RouteConfig
|
||||
{
|
||||
RouteId = "route1",
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
Match = new RouteMatch
|
||||
{
|
||||
Hosts = new[] { "localhost" },
|
||||
},
|
||||
ClusterId = "cluster1",
|
||||
};
|
||||
|
||||
var services = CreateServices();
|
||||
var validator = services.GetRequiredService<IConfigValidator>();
|
||||
|
||||
var result = await validator.ValidateRouteAsync(route);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_UnknownTimeoutPolicy()
|
||||
{
|
||||
var route = new RouteConfig
|
||||
{
|
||||
RouteId = "route1",
|
||||
TimeoutPolicy = "unknown",
|
||||
ClusterId = "cluster1",
|
||||
Match = new RouteMatch(),
|
||||
};
|
||||
|
||||
var services = CreateServices();
|
||||
var validator = services.GetRequiredService<IConfigValidator>();
|
||||
|
||||
var result = await validator.ValidateRouteAsync(route);
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.Contains(result, err => err.Message.Equals("Timeout policy 'unknown' not found for route 'route1'."));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public async Task Rejects_InvalidTimeouts(int timeout)
|
||||
{
|
||||
var route = new RouteConfig
|
||||
{
|
||||
RouteId = "route1",
|
||||
Timeout = TimeSpan.FromMilliseconds(timeout),
|
||||
ClusterId = "cluster1",
|
||||
Match = new RouteMatch(),
|
||||
};
|
||||
|
||||
var services = CreateServices();
|
||||
var validator = services.GetRequiredService<IConfigValidator>();
|
||||
|
||||
var result = await validator.ValidateRouteAsync(route);
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.Contains(result, err => err.Message.Equals($"The Timeout value '{TimeSpan.FromMilliseconds(timeout)}' is invalid for route 'route1'. The Timeout must be greater than zero milliseconds."));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_TimeoutWithTimeoutPolicy()
|
||||
{
|
||||
var route = new RouteConfig
|
||||
{
|
||||
RouteId = "route1",
|
||||
TimeoutPolicy = "unknown",
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
ClusterId = "cluster1",
|
||||
Match = new RouteMatch(),
|
||||
};
|
||||
|
||||
var services = CreateServices();
|
||||
var validator = services.GetRequiredService<IConfigValidator>();
|
||||
|
||||
var result = await validator.ValidateRouteAsync(route);
|
||||
|
||||
Assert.NotEmpty(result);
|
||||
Assert.Contains(result, err => err.Message.Equals("Timeout policy 'unknown' not found for route 'route1'."));
|
||||
}
|
||||
#endif
|
||||
[Theory]
|
||||
[InlineData(null)]
|
||||
[InlineData("")]
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
// Copyright (c) Microsoft Corporation.
|
||||
// Licensed under the MIT License.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using Xunit;
|
||||
|
||||
namespace Yarp.ReverseProxy.Configuration.Tests;
|
||||
|
@ -17,6 +19,10 @@ public class RouteConfigTests
|
|||
AuthorizationPolicy = "a",
|
||||
#if NET7_0_OR_GREATER
|
||||
RateLimiterPolicy = "rl",
|
||||
#endif
|
||||
#if NET8_0_OR_GREATER
|
||||
TimeoutPolicy = "t",
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
#endif
|
||||
ClusterId = "c",
|
||||
CorsPolicy = "co",
|
||||
|
@ -48,6 +54,10 @@ public class RouteConfigTests
|
|||
AuthorizationPolicy = "A",
|
||||
#if NET7_0_OR_GREATER
|
||||
RateLimiterPolicy = "RL",
|
||||
#endif
|
||||
#if NET8_0_OR_GREATER
|
||||
TimeoutPolicy = "T",
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
#endif
|
||||
ClusterId = "C",
|
||||
CorsPolicy = "Co",
|
||||
|
@ -90,6 +100,10 @@ public class RouteConfigTests
|
|||
AuthorizationPolicy = "a",
|
||||
#if NET7_0_OR_GREATER
|
||||
RateLimiterPolicy = "rl",
|
||||
#endif
|
||||
#if NET8_0_OR_GREATER
|
||||
TimeoutPolicy = "t",
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
#endif
|
||||
ClusterId = "c",
|
||||
CorsPolicy = "co",
|
||||
|
@ -126,6 +140,10 @@ public class RouteConfigTests
|
|||
#if NET7_0_OR_GREATER
|
||||
var i = a with { RateLimiterPolicy = "i" };
|
||||
#endif
|
||||
#if NET8_0_OR_GREATER
|
||||
var j = a with { TimeoutPolicy = "j" };
|
||||
var k = a with { Timeout = TimeSpan.FromSeconds(107) };
|
||||
#endif
|
||||
|
||||
Assert.False(a.Equals(b));
|
||||
Assert.False(a.Equals(c));
|
||||
|
@ -136,6 +154,10 @@ public class RouteConfigTests
|
|||
Assert.False(a.Equals(h));
|
||||
#if NET7_0_OR_GREATER
|
||||
Assert.False(a.Equals(i));
|
||||
#endif
|
||||
#if NET8_0_OR_GREATER
|
||||
Assert.False(a.Equals(j));
|
||||
Assert.False(a.Equals(k));
|
||||
#endif
|
||||
}
|
||||
|
||||
|
|
|
@ -7,6 +7,9 @@ using System.Net.Http;
|
|||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
#if NET8_0_OR_GREATER
|
||||
using Microsoft.AspNetCore.Http.Timeouts;
|
||||
#endif
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.AspNetCore.Routing.Patterns;
|
||||
using Moq;
|
||||
|
@ -119,14 +122,44 @@ public class ProxyPipelineInitializerMiddlewareTests : TestAutoMockBase
|
|||
|
||||
Assert.Equal(StatusCodes.Status418ImATeapot, httpContext.Response.StatusCode);
|
||||
}
|
||||
#if NET8_0_OR_GREATER
|
||||
[Fact]
|
||||
public async Task Invoke_MissingTimeoutMiddleware_RefuseRequest()
|
||||
{
|
||||
var httpClient = new HttpMessageInvoker(new Mock<HttpMessageHandler>().Object);
|
||||
var cluster1 = new ClusterState(clusterId: "cluster1")
|
||||
{
|
||||
Model = new ClusterModel(new ClusterConfig(), httpClient)
|
||||
};
|
||||
|
||||
private static Endpoint CreateAspNetCoreEndpoint(RouteModel routeConfig)
|
||||
var aspNetCoreEndpoints = new List<Endpoint>();
|
||||
var routeConfig = new RouteModel(
|
||||
config: new RouteConfig(),
|
||||
cluster: cluster1,
|
||||
transformer: HttpTransformer.Default);
|
||||
var aspNetCoreEndpoint = CreateAspNetCoreEndpoint(routeConfig,
|
||||
builder =>
|
||||
{
|
||||
builder.Metadata.Add(new RequestTimeoutAttribute(1));
|
||||
});
|
||||
aspNetCoreEndpoints.Add(aspNetCoreEndpoint);
|
||||
var httpContext = new DefaultHttpContext();
|
||||
httpContext.SetEndpoint(aspNetCoreEndpoint);
|
||||
|
||||
var sut = Create<ProxyPipelineInitializerMiddleware>();
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() => sut.Invoke(httpContext));
|
||||
}
|
||||
#endif
|
||||
|
||||
private static Endpoint CreateAspNetCoreEndpoint(RouteModel routeConfig, Action<RouteEndpointBuilder> configure = null)
|
||||
{
|
||||
var endpointBuilder = new RouteEndpointBuilder(
|
||||
requestDelegate: httpContext => Task.CompletedTask,
|
||||
routePattern: RoutePatternFactory.Parse("/"),
|
||||
order: 0);
|
||||
endpointBuilder.Metadata.Add(routeConfig);
|
||||
configure?.Invoke(endpointBuilder);
|
||||
return endpointBuilder.Build();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,6 +5,9 @@ using Microsoft.AspNetCore.Authorization;
|
|||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Cors;
|
||||
using Microsoft.AspNetCore.Cors.Infrastructure;
|
||||
#if NET8_0_OR_GREATER
|
||||
using Microsoft.AspNetCore.Http.Timeouts;
|
||||
#endif
|
||||
#if NET7_0_OR_GREATER
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
#endif
|
||||
|
@ -418,6 +421,104 @@ public class ProxyEndpointFactoryTests
|
|||
Assert.Null(routeEndpoint.Metadata.GetMetadata<DisableRateLimitingAttribute>());
|
||||
}
|
||||
#endif
|
||||
#if NET8_0_OR_GREATER
|
||||
[Fact]
|
||||
public void AddEndpoint_CustomTimeoutPolicy_Works()
|
||||
{
|
||||
var services = CreateServices();
|
||||
var factory = services.GetRequiredService<ProxyEndpointFactory>();
|
||||
factory.SetProxyPipeline(context => Task.CompletedTask);
|
||||
|
||||
var route = new RouteConfig
|
||||
{
|
||||
RouteId = "route1",
|
||||
TimeoutPolicy = "custom",
|
||||
Order = 12,
|
||||
Match = new RouteMatch(),
|
||||
};
|
||||
var cluster = new ClusterState("cluster1");
|
||||
var routeState = new RouteState("route1");
|
||||
|
||||
var (routeEndpoint, _) = CreateEndpoint(factory, routeState, route, cluster);
|
||||
|
||||
var attribute = routeEndpoint.Metadata.GetMetadata<RequestTimeoutAttribute>();
|
||||
Assert.NotNull(attribute);
|
||||
Assert.Equal("custom", attribute.PolicyName);
|
||||
Assert.Null(attribute.Timeout);
|
||||
Assert.Null(routeEndpoint.Metadata.GetMetadata<DisableRequestTimeoutAttribute>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddEndpoint_CustomTimeout_Works()
|
||||
{
|
||||
var services = CreateServices();
|
||||
var factory = services.GetRequiredService<ProxyEndpointFactory>();
|
||||
factory.SetProxyPipeline(context => Task.CompletedTask);
|
||||
|
||||
var route = new RouteConfig
|
||||
{
|
||||
RouteId = "route1",
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
Order = 12,
|
||||
Match = new RouteMatch(),
|
||||
};
|
||||
var cluster = new ClusterState("cluster1");
|
||||
var routeState = new RouteState("route1");
|
||||
|
||||
var (routeEndpoint, _) = CreateEndpoint(factory, routeState, route, cluster);
|
||||
|
||||
var attribute = routeEndpoint.Metadata.GetMetadata<RequestTimeoutAttribute>();
|
||||
Assert.NotNull(attribute);
|
||||
Assert.Null(attribute.PolicyName);
|
||||
Assert.Equal(TimeSpan.FromSeconds(5), attribute.Timeout);
|
||||
Assert.Null(routeEndpoint.Metadata.GetMetadata<DisableRequestTimeoutAttribute>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddEndpoint_DisableTimeoutPolicy_Works()
|
||||
{
|
||||
var services = CreateServices();
|
||||
var factory = services.GetRequiredService<ProxyEndpointFactory>();
|
||||
factory.SetProxyPipeline(context => Task.CompletedTask);
|
||||
|
||||
var route = new RouteConfig
|
||||
{
|
||||
RouteId = "route1",
|
||||
TimeoutPolicy = "disAble",
|
||||
Order = 12,
|
||||
Match = new RouteMatch(),
|
||||
};
|
||||
var cluster = new ClusterState("cluster1");
|
||||
var routeState = new RouteState("route1");
|
||||
|
||||
var (routeEndpoint, _) = CreateEndpoint(factory, routeState, route, cluster);
|
||||
|
||||
Assert.NotNull(routeEndpoint.Metadata.GetMetadata<DisableRequestTimeoutAttribute>());
|
||||
Assert.Null(routeEndpoint.Metadata.GetMetadata<RequestTimeoutAttribute>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddEndpoint_NoTimeoutPolicy_Works()
|
||||
{
|
||||
var services = CreateServices();
|
||||
var factory = services.GetRequiredService<ProxyEndpointFactory>();
|
||||
factory.SetProxyPipeline(context => Task.CompletedTask);
|
||||
|
||||
var route = new RouteConfig
|
||||
{
|
||||
RouteId = "route1",
|
||||
Order = 12,
|
||||
Match = new RouteMatch(),
|
||||
};
|
||||
var cluster = new ClusterState("cluster1");
|
||||
var routeState = new RouteState("route1");
|
||||
|
||||
var (routeEndpoint, _) = CreateEndpoint(factory, routeState, route, cluster);
|
||||
|
||||
Assert.Null(routeEndpoint.Metadata.GetMetadata<RequestTimeoutAttribute>());
|
||||
Assert.Null(routeEndpoint.Metadata.GetMetadata<DisableRequestTimeoutAttribute>());
|
||||
}
|
||||
#endif
|
||||
|
||||
[Fact]
|
||||
public void AddEndpoint_DefaultCors_Works()
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net6.0;net7.0</TargetFrameworks>
|
||||
<TargetFrameworks>net6.0;net7.0;net8.0</TargetFrameworks>
|
||||
<OutputType>Exe</OutputType>
|
||||
<RootNamespace>Yarp.ReverseProxy.Sample</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Net.Http.Headers;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
|
@ -35,7 +36,10 @@ public class Startup
|
|||
Match = new RouteMatch
|
||||
{
|
||||
Path = "{**catch-all}"
|
||||
}
|
||||
},
|
||||
#if NET8_0_OR_GREATER
|
||||
Timeout = TimeSpan.FromSeconds(5),
|
||||
#endif
|
||||
}
|
||||
};
|
||||
var clusters = new[]
|
||||
|
@ -72,7 +76,7 @@ public class Startup
|
|||
// For each route+cluster pair decide if we want to add transforms, and if so, which?
|
||||
// This logic is re-run each time a route is rebuilt.
|
||||
|
||||
transformBuilderContext.AddPathPrefix("/prefix");
|
||||
// transformBuilderContext.AddPathPrefix("/prefix");
|
||||
|
||||
// Only do this for routes that require auth.
|
||||
if (string.Equals("token", transformBuilderContext.Route.AuthorizationPolicy))
|
||||
|
@ -104,6 +108,16 @@ public class Startup
|
|||
services.AddSingleton<IMetricsConsumer<ForwarderMetrics>, ForwarderMetricsConsumer>();
|
||||
services.AddTelemetryConsumer<ForwarderTelemetryConsumer>();
|
||||
services.AddTelemetryListeners();
|
||||
#if NET8_0_OR_GREATER
|
||||
services.AddRequestTimeouts(o =>
|
||||
{
|
||||
o.DefaultPolicy = new Microsoft.AspNetCore.Http.Timeouts.RequestTimeoutPolicy()
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(1),
|
||||
TimeoutStatusCode = StatusCodes.Status418ImATeapot,
|
||||
};
|
||||
});
|
||||
#endif
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
@ -113,6 +127,9 @@ public class Startup
|
|||
{
|
||||
app.UseRouting();
|
||||
app.UseAuthorization();
|
||||
#if NET8_0_OR_GREATER
|
||||
app.UseRequestTimeouts();
|
||||
#endif
|
||||
app.UseEndpoints(endpoints =>
|
||||
{
|
||||
endpoints.MapControllers();
|
||||
|
|
|
@ -37,6 +37,18 @@ public class HttpController : ControllerBase
|
|||
return StatusCode(StatusCodes.Status409Conflict);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a 409 response without consuming the request body.
|
||||
/// This is used to exercise <c>Expect:100-continue</c> behavior.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
[Route("/api/slow")]
|
||||
public async Task<IActionResult> Slow()
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(3));
|
||||
return StatusCode(StatusCodes.Status200OK);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a 200 response dumping all info from the incoming request.
|
||||
/// </summary>
|
||||
|
|
Загрузка…
Ссылка в новой задаче