Skip to content

Commit

Permalink
Timeout integration (#2307)
Browse files Browse the repository at this point in the history
  • Loading branch information
Tratcher authored Nov 14, 2023
1 parent 01ed48e commit 48e3f7e
Show file tree
Hide file tree
Showing 22 changed files with 575 additions and 8 deletions.
75 changes: 75 additions & 0 deletions docs/docfx/articles/timeouts.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions docs/docfx/articles/toc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/docfx/articles/websockets.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
11 changes: 9 additions & 2 deletions samples/ReverseProxy.Minimal.Sample/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,16 @@

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();
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
49 changes: 48 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,37 @@ 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.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))
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
9 changes: 9 additions & 0 deletions src/ReverseProxy/Configuration/TimeoutPolicyConstants.cs
Original file line number Diff line number Diff line change
@@ -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";
}
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
30 changes: 29 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,19 @@ 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
&& 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
{
Expand Down Expand Up @@ -80,9 +98,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

0 comments on commit 48e3f7e

Please sign in to comment.