Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Timeout integration #2307

Merged
merged 6 commits into from
Nov 14, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions docs/docfx/articles/timeouts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
# 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 can be configured in Startup.ConfigureServices as follows:
Tratcher marked this conversation as resolved.
Show resolved Hide resolved
```
public void ConfigureServices(IServiceCollection services)
{
services.AddRequestTimeouts(options =>
{
options.AddPolicy("customPolicy", TimeSpan.FromSeconds(20));
});
}
```

In Startup.Configure add the timeout middleware between Routing and Endpoints.

```
public void Configure(IApplicationBuilder app)
{
app.UseRouting();

app.UseRequestTimeouts();

app.UseEndpoints(endpoints =>
{
endpoints.MapReverseProxy();
});
}
```


### DefaultPolicy

Specifying the value `default` in a route's `TimeoutPolicy` parameter means that route will use the policy defined in [RequestTimeoutOptions.DefaultPolicy](https://learn.microsoft.com/dotnet/api/microsoft.aspnetcore.http.timeouts.requesttimeoutoptions.defaultpolicy#microsoft-aspnetcore-http-timeouts-requesttimeoutoptions-defaultpolicy).

### 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.
3 changes: 3 additions & 0 deletions src/Kubernetes.Controller/Converters/YarpIngressOptions.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System;
using System.Collections.Generic;
using Yarp.ReverseProxy.Configuration;

Expand All @@ -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; }
Expand Down
4 changes: 4 additions & 0 deletions src/Kubernetes.Controller/Converters/YarpParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ private static RouteConfig CreateRoute(YarpIngressContext ingressContext, V1HTTP
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,10 @@ private static RouteConfig CreateRoute(IConfigurationSection section)
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(),
Expand Down
50 changes: 49 additions & 1 deletion src/ReverseProxy/Configuration/ConfigValidator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,14 @@
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;
Expand All @@ -32,18 +38,23 @@ 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;
private readonly FrozenDictionary<string, IActiveHealthCheckPolicy> _activeHealthCheckPolicies;
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,
Expand All @@ -55,6 +66,9 @@ public ConfigValidator(ITransformBuilder transformBuilder,
_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));
Expand All @@ -78,6 +92,9 @@ public async ValueTask<IList<Exception>> ValidateRouteAsync(RouteConfig route)
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);

Expand Down Expand Up @@ -294,7 +311,38 @@ private async ValueTask ValidateAuthorizationPolicyAsync(IList<Exception> errors
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.Default, timeoutPolicyName, StringComparison.OrdinalIgnoreCase)
|| 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.TotalMicroseconds <= 0)
Tratcher marked this conversation as resolved.
Show resolved Hide resolved
{
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))
Expand Down
25 changes: 25 additions & 0 deletions src/ReverseProxy/Configuration/RouteConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -89,6 +106,10 @@ public bool Equals(RouteConfig? other)
&& 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
Expand All @@ -106,6 +127,10 @@ public override int GetHashCode()
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);
Expand Down
10 changes: 10 additions & 0 deletions src/ReverseProxy/Configuration/TimeoutPolicyConstants.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Yarp.ReverseProxy.Configuration;

internal static class TimeoutPolicyConstants
{
internal const string Default = "Default";
internal const string Disable = "Disable";
}
7 changes: 7 additions & 0 deletions src/ReverseProxy/Forwarder/HttpForwarder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
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;
Expand Down Expand Up @@ -719,6 +722,10 @@ private async ValueTask<ForwarderError> HandleUpgradedResponse(HttpContext conte
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)
{
Expand Down
28 changes: 27 additions & 1 deletion src/ReverseProxy/Model/ProxyPipelineInitializerMiddleware.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@
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;
Expand Down Expand Up @@ -41,7 +47,17 @@ public Task Invoke(HttpContext context)
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
Tratcher marked this conversation as resolved.
Show resolved Hide resolved
&& context.Features.Get<IHttpRequestTimeoutFeature>() == null)
{
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
{
Expand Down Expand Up @@ -80,9 +96,19 @@ private static class Log
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);
}
}
}
Loading
Loading