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 2 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.
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);
}
}
}
1 change: 1 addition & 0 deletions src/ReverseProxy/Utilities/EventIds.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
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;
Expand Down Expand Up @@ -119,14 +122,85 @@ public async Task Invoke_NoHealthyEndpoints_CallsNext()

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)
};

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 sut.Invoke(httpContext);

Assert.Equal(StatusCodes.Status503ServiceUnavailable, httpContext.Response.StatusCode);
}

[Fact]
public async Task Invoke_MissingTimeoutMiddleware_DefaultPolicyAllowed()
{
var httpClient = new HttpMessageInvoker(new Mock<HttpMessageHandler>().Object);
var cluster1 = new ClusterState(clusterId: "cluster1");
cluster1.Model = new ClusterModel(new ClusterConfig(), httpClient);
var destination1 = cluster1.Destinations.GetOrAdd(
"destination1",
id => new DestinationState(id) { Model = new DestinationModel(new DestinationConfig { Address = "https://localhost:123/a/b/" }) });
cluster1.DestinationsState = new ClusterDestinationsState(new[] { destination1 }, new[] { destination1 });

var aspNetCoreEndpoints = new List<Endpoint>();
var routeConfig = new RouteModel(
config: new RouteConfig(),
cluster1,
HttpTransformer.Default);
var aspNetCoreEndpoint = CreateAspNetCoreEndpoint(routeConfig,
builder =>
{
builder.Metadata.Add(new RequestTimeoutAttribute(TimeoutPolicyConstants.Default));
});
aspNetCoreEndpoints.Add(aspNetCoreEndpoint);
var httpContext = new DefaultHttpContext();
httpContext.SetEndpoint(aspNetCoreEndpoint);

var sut = Create<ProxyPipelineInitializerMiddleware>();

await sut.Invoke(httpContext);

var proxyFeature = httpContext.GetReverseProxyFeature();
Assert.NotNull(proxyFeature);
Assert.NotNull(proxyFeature.AvailableDestinations);
Assert.Single(proxyFeature.AvailableDestinations);
Assert.Same(destination1, proxyFeature.AvailableDestinations[0]);
Assert.Same(cluster1.Model, proxyFeature.Cluster);

Assert.Equal(StatusCodes.Status418ImATeapot, httpContext.Response.StatusCode);
}
#endif

private static Endpoint CreateAspNetCoreEndpoint(RouteModel routeConfig)
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();
}
}
Loading