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 all 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
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)
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
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
Tratcher marked this conversation as resolved.
Show resolved Hide resolved
&& 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
Loading