This commit is contained in:
Chris Ross 2023-11-14 11:17:36 -08:00 коммит произвёл GitHub
Родитель 01ed48e19d
Коммит 48e3f7eaaa
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
22 изменённых файлов: 575 добавлений и 8 удалений

Просмотреть файл

@ -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>