Skip to content
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
79 changes: 79 additions & 0 deletions src/Teapot.Web.Tests/IntegrationTests/TimeoutTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
using Microsoft.AspNetCore.Mvc.Testing;

namespace Teapot.Web.Tests.IntegrationTests;

[TestFixtureSource(typeof(HttpMethods), nameof(HttpMethods.All))]
public class TimeoutTests(HttpMethod httpMethod)
{
[OneTimeSetUp]
public void OneTimeSetUp()
{
_httpClient = new WebApplicationFactory<Program>().CreateDefaultClient();
}

[OneTimeTearDown]
public void OneTimeTearDown()
{
_httpClient.Dispose();
}

private HttpClient _httpClient = null!;

[Test]
public async Task SleepExceedsMaxTimeout_ReturnsBadRequest()
{
// Default max timeout is 30 seconds (30,000 ms), try 60 seconds
string uri = "/200?sleep=60000";
using HttpRequestMessage httpRequest = new(httpMethod, uri);
using HttpResponseMessage response = await _httpClient.SendAsync(httpRequest);
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}

[Test]
public async Task SleepAfterHeadersExceedsMaxTimeout_ReturnsBadRequest()
{
// Default max timeout is 30 seconds (30,000 ms), try 60 seconds via header
string uri = "/200";
using HttpRequestMessage httpRequest = new(httpMethod, uri);
httpRequest.Headers.Add("X-HttpStatus-SleepAfterHeaders", "60000");
using HttpResponseMessage response = await _httpClient.SendAsync(httpRequest);
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}

[Test]
public async Task SleepWithinMaxTimeout_Succeeds()
{
// Default max timeout is 30 seconds (30,000 ms), try 5 seconds
string uri = "/200?sleep=5000";
using HttpRequestMessage httpRequest = new(httpMethod, uri);
using HttpResponseMessage response = await _httpClient.SendAsync(httpRequest);
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));

string body = await response.Content.ReadAsStringAsync();
Assert.That(body, Does.Contain("200"));
}

[Test]
public async Task SleepEqualToMaxTimeout_Succeeds()
{
// Default max timeout is 30 seconds (30,000 ms), try exactly 30 seconds
// But for testing purposes, let's use a smaller value to avoid long test runs
string uri = "/200?sleep=1000"; // 1 second - well within limit
using HttpRequestMessage httpRequest = new(httpMethod, uri);
using HttpResponseMessage response = await _httpClient.SendAsync(httpRequest);
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));

string body = await response.Content.ReadAsStringAsync();
Assert.That(body, Does.Contain("200"));
}

[Test]
public async Task SleepJustOverMaxTimeout_ReturnsBadRequest()
{
// Default max timeout is 30 seconds (30,000 ms), try 30,001 ms
string uri = "/200?sleep=30001";
using HttpRequestMessage httpRequest = new(httpMethod, uri);
using HttpResponseMessage response = await _httpClient.SendAsync(httpRequest);
Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest));
}
}
133 changes: 129 additions & 4 deletions src/Teapot.Web.Tests/UnitTests/SleepTests.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Teapot.Web.Configuration;
using Teapot.Web.Models;
using Teapot.Web.Models.Unofficial;

Expand All @@ -7,6 +9,7 @@ public class SleepTests {
private const int Sleep = 500;

private TeapotStatusCodeMetadataCollection _statusCodes;
private TimeoutOptions _timeoutOptions;

[SetUp]
public void Setup() {
Expand All @@ -19,13 +22,14 @@ public void Setup() {
new NginxStatusCodeMetadata(),
new TwitterStatusCodeMetadata()
);
_timeoutOptions = new TimeoutOptions();
}

[Test]
public void SleepReadFromQuery()
{
Mock<HttpRequest> request = HttpRequestHelper.GenerateMockRequest();
IResult result = StatusExtensions.CommonHandleStatusRequestAsync(new ResponseOptions(200, new(), sleep: Sleep), null, request.Object);
IResult result = StatusExtensions.CommonHandleStatusRequestAsync(new ResponseOptions(200, new(), sleep: Sleep), null, request.Object, _timeoutOptions);

Assert.Multiple(() =>
{
Expand All @@ -42,7 +46,7 @@ public void SleepReadFromHeader()
Mock<HttpRequest> request = HttpRequestHelper.GenerateMockRequest();
request.Object.Headers.Append(StatusExtensions.SLEEP_HEADER, Sleep.ToString());

IResult result = StatusExtensions.CommonHandleStatusRequestAsync(new ResponseOptions(200, new()), null, request.Object);
IResult result = StatusExtensions.CommonHandleStatusRequestAsync(new ResponseOptions(200, new()), null, request.Object, _timeoutOptions);

Assert.Multiple(() => {
Assert.That(result, Is.InstanceOf<CustomHttpStatusCodeResult>());
Expand All @@ -57,7 +61,7 @@ public void SleepReadFromQSTakesPriorityHeader() {
Mock<HttpRequest> request = HttpRequestHelper.GenerateMockRequest();
request.Object.Headers.Append(StatusExtensions.SLEEP_HEADER, Sleep.ToString());

IResult result = StatusExtensions.CommonHandleStatusRequestAsync(new ResponseOptions(200, new(), sleep: Sleep * 2), null, request.Object);
IResult result = StatusExtensions.CommonHandleStatusRequestAsync(new ResponseOptions(200, new(), sleep: Sleep * 2), null, request.Object, _timeoutOptions);

Assert.Multiple(() => {
Assert.That(result, Is.InstanceOf<CustomHttpStatusCodeResult>());
Expand All @@ -73,7 +77,7 @@ public void BadSleepHeaderIgnored()
Mock<HttpRequest> request = HttpRequestHelper.GenerateMockRequest();
request.Object.Headers.Append(StatusExtensions.SLEEP_HEADER, "invalid");

IResult result = StatusExtensions.CommonHandleStatusRequestAsync(new ResponseOptions(200, new()), null, request.Object);
IResult result = StatusExtensions.CommonHandleStatusRequestAsync(new ResponseOptions(200, new()), null, request.Object, _timeoutOptions);

Assert.Multiple(() =>
{
Expand All @@ -84,4 +88,125 @@ public void BadSleepHeaderIgnored()
});
}

[Test]
public void SleepMaxTimeout_DefaultIsThirtySeconds()
{
// Verify default timeout is 30 seconds (30,000 ms)
var defaultTimeoutOptions = new TimeoutOptions();
Assert.That(defaultTimeoutOptions.MaxSleepMilliseconds, Is.EqualTo(30 * 1000));
}

[Test]
public void SleepMaxTimeout_ConfigurableViaOptions()
{
// Verify timeout can be configured
var customTimeoutOptions = new TimeoutOptions { MaxSleepMilliseconds = 60 * 1000 }; // 1 minute
Mock<HttpRequest> request = HttpRequestHelper.GenerateMockRequest();

IResult result = StatusExtensions.CommonHandleStatusRequestAsync(
new ResponseOptions(200, new(), sleep: 45 * 1000), // 45 seconds
null,
request.Object,
customTimeoutOptions);

Assert.Multiple(() =>
{
Assert.That(result, Is.InstanceOf<CustomHttpStatusCodeResult>());
CustomHttpStatusCodeResult r = (CustomHttpStatusCodeResult)result;
Assert.That(r.Options.Sleep, Is.EqualTo(45 * 1000));
});
}

[Test]
public void SleepMaxTimeout_ExcessiveValuesReturnBadRequest()
{
// Test that values exceeding the max now return BadRequest instead of being clamped
var timeoutOptions = new TimeoutOptions { MaxSleepMilliseconds = 30 * 1000 }; // 30 seconds
Mock<HttpRequest> request = HttpRequestHelper.GenerateMockRequest();

IResult result = StatusExtensions.CommonHandleStatusRequestAsync(
new ResponseOptions(200, new(), sleep: 60 * 1000), // 60 seconds - exceeds 30 second max
null,
request.Object,
timeoutOptions);

// Should now return BadRequest instead of being clamped
Assert.That(result, Is.InstanceOf<BadRequest>());
}

[Test]
public void SleepExceedsMaxTimeout_ReturnsBadRequest()
{
// Test that sleep values exceeding the max timeout return BadRequest
var timeoutOptions = new TimeoutOptions { MaxSleepMilliseconds = 30 * 1000 }; // 30 seconds
Mock<HttpRequest> request = HttpRequestHelper.GenerateMockRequest();

IResult result = StatusExtensions.CommonHandleStatusRequestAsync(
new ResponseOptions(200, new(), sleep: 60 * 1000), // 60 seconds - exceeds 30 second max
null,
request.Object,
timeoutOptions);

Assert.That(result, Is.InstanceOf<BadRequest>());
}

[Test]
public void SleepAfterHeadersExceedsMaxTimeout_ReturnsBadRequest()
{
// Test that sleepAfterHeaders values exceeding the max timeout return BadRequest
var timeoutOptions = new TimeoutOptions { MaxSleepMilliseconds = 30 * 1000 }; // 30 seconds
Mock<HttpRequest> request = HttpRequestHelper.GenerateMockRequest();
request.Object.Headers.Append(StatusExtensions.SLEEP_AFTER_HEADERS, (60 * 1000).ToString()); // 60 seconds

IResult result = StatusExtensions.CommonHandleStatusRequestAsync(
new ResponseOptions(200, new()),
null,
request.Object,
timeoutOptions);

Assert.That(result, Is.InstanceOf<BadRequest>());
}

[Test]
public void SleepWithinMaxTimeout_Succeeds()
{
// Test that sleep values within the max timeout work normally
var timeoutOptions = new TimeoutOptions { MaxSleepMilliseconds = 30 * 1000 }; // 30 seconds
Mock<HttpRequest> request = HttpRequestHelper.GenerateMockRequest();

IResult result = StatusExtensions.CommonHandleStatusRequestAsync(
new ResponseOptions(200, new(), sleep: 15 * 1000), // 15 seconds - within 30 second max
null,
request.Object,
timeoutOptions);

Assert.Multiple(() =>
{
Assert.That(result, Is.InstanceOf<CustomHttpStatusCodeResult>());
CustomHttpStatusCodeResult r = (CustomHttpStatusCodeResult)result;
Assert.That(r.Options.Sleep, Is.EqualTo(15 * 1000));
});
}

[Test]
public void SleepEqualToMaxTimeout_Succeeds()
{
// Test that sleep values equal to the max timeout work normally
var timeoutOptions = new TimeoutOptions { MaxSleepMilliseconds = 30 * 1000 }; // 30 seconds
Mock<HttpRequest> request = HttpRequestHelper.GenerateMockRequest();

IResult result = StatusExtensions.CommonHandleStatusRequestAsync(
new ResponseOptions(200, new(), sleep: 30 * 1000), // 30 seconds - exactly at max
null,
request.Object,
timeoutOptions);

Assert.Multiple(() =>
{
Assert.That(result, Is.InstanceOf<CustomHttpStatusCodeResult>());
CustomHttpStatusCodeResult r = (CustomHttpStatusCodeResult)result;
Assert.That(r.Options.Sleep, Is.EqualTo(30 * 1000));
});
}

}
11 changes: 7 additions & 4 deletions src/Teapot.Web.Tests/UnitTests/SuppressBodyTests.cs
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
using Microsoft.AspNetCore.Http;
using Teapot.Web.Configuration;
using Teapot.Web.Models;
using Teapot.Web.Models.Unofficial;

namespace Teapot.Web.Tests.UnitTests;
public class SuppressBodyTests
{
private TeapotStatusCodeMetadataCollection _statusCodes;
private TimeoutOptions _timeoutOptions;

[SetUp]
public void Setup()
Expand All @@ -19,6 +21,7 @@ public void Setup()
new NginxStatusCodeMetadata(),
new TwitterStatusCodeMetadata()
);
_timeoutOptions = new TimeoutOptions();
}

[Test]
Expand All @@ -28,7 +31,7 @@ public void Setup()
public void SuppressBodyReadFromQuery(bool? suppressBody)
{
Mock<HttpRequest> request = HttpRequestHelper.GenerateMockRequest();
IResult result = StatusExtensions.CommonHandleStatusRequestAsync(new ResponseOptions(200, new(), suppressBody: suppressBody), null, request.Object);
IResult result = StatusExtensions.CommonHandleStatusRequestAsync(new ResponseOptions(200, new(), suppressBody: suppressBody), null, request.Object, _timeoutOptions);

Assert.Multiple(() =>
{
Expand All @@ -48,7 +51,7 @@ public void SuppressBodyReadFromHeader(string? suppressBody)
Mock<HttpRequest> request = HttpRequestHelper.GenerateMockRequest();
request.Object.Headers.Append(StatusExtensions.SUPPRESS_BODY_HEADER, suppressBody);

IResult result = StatusExtensions.CommonHandleStatusRequestAsync(new ResponseOptions(200, new()), null, request.Object);
IResult result = StatusExtensions.CommonHandleStatusRequestAsync(new ResponseOptions(200, new()), null, request.Object, _timeoutOptions);

Assert.Multiple(() =>
{
Expand Down Expand Up @@ -76,7 +79,7 @@ public void SuppressBodyReadFromQSTakesPriorityHeader(string? headerValue, bool?
Mock<HttpRequest> request = HttpRequestHelper.GenerateMockRequest();
request.Object.Headers.Append(StatusExtensions.SUPPRESS_BODY_HEADER, headerValue);

IResult result = StatusExtensions.CommonHandleStatusRequestAsync(new ResponseOptions(200, new(), suppressBody: queryStringValue), null, request.Object);
IResult result = StatusExtensions.CommonHandleStatusRequestAsync(new ResponseOptions(200, new(), suppressBody: queryStringValue), null, request.Object, _timeoutOptions);

Assert.Multiple(() =>
{
Expand All @@ -98,7 +101,7 @@ public void BadSuppressBodyHeaderIgnored()
Mock<HttpRequest> request = HttpRequestHelper.GenerateMockRequest();
request.Object.Headers.Append(StatusExtensions.SUPPRESS_BODY_HEADER, "invalid");

IResult result = StatusExtensions.CommonHandleStatusRequestAsync(new ResponseOptions(200, new()), null, request.Object);
IResult result = StatusExtensions.CommonHandleStatusRequestAsync(new ResponseOptions(200, new()), null, request.Object, _timeoutOptions);

Assert.Multiple(() =>
{
Expand Down
14 changes: 14 additions & 0 deletions src/Teapot.Web/Configuration/TimeoutOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
using System.ComponentModel.DataAnnotations;

namespace Teapot.Web.Configuration;

public class TimeoutOptions
{
public const string SectionName = "Timeout";

/// <summary>
/// Maximum sleep timeout in milliseconds. Default is 30 seconds (30,000 ms).
/// </summary>
[Range(0, int.MaxValue, ErrorMessage = "MaxSleepMilliseconds must be a non-negative value")]
public int MaxSleepMilliseconds { get; set; } = 30 * 1000; // 30 seconds default
}
6 changes: 3 additions & 3 deletions src/Teapot.Web/CustomHttpStatusCodeResult.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@

namespace Teapot.Web;

public class CustomHttpStatusCodeResult(ResponseOptions options) : IResult
public class CustomHttpStatusCodeResult(ResponseOptions options, int maxSleepMilliseconds = 30 * 1000) : IResult
{
private const int SLEEP_MIN = 0;
private const int SLEEP_MAX = 5 * 60 * 1000; // 5 mins in milliseconds
private readonly int SLEEP_MAX = maxSleepMilliseconds;
internal static readonly string[] onlySingleHeader = ["Location", "Retry-After"];

private static readonly MediaTypeHeaderValue jsonMimeType = new("application/json");
Expand Down Expand Up @@ -120,7 +120,7 @@ public async Task ExecuteAsync(HttpContext context)
}
}

private static async Task DoSleep(int? sleep)
private async Task DoSleep(int? sleep)
{
int sleepData = Math.Clamp(sleep ?? 0, SLEEP_MIN, SLEEP_MAX);
if (sleepData > 0)
Expand Down
4 changes: 2 additions & 2 deletions src/Teapot.Web/Pages/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@
</p>

<p>
If you want a delay on the response add a query string or provide a header of <code>@StatusExtensions.SLEEP_HEADER</code> for the sleep duration (the time in ms, max 5 minutes*), like this:
If you want a delay on the response add a query string or provide a header of <code>@StatusExtensions.SLEEP_HEADER</code> for the sleep duration (the time in ms, max 30 seconds*), like this:
<a href="/200?sleep=5000" target="_blank">httpstat.us/200?sleep=5000</a>.
<br />
<em>*When using the hosted instance the timeout is actually 230 seconds, which is the max timeout allowed by an Azure App Service (see <a href="https://social.msdn.microsoft.com/Forums/en-US/05f254a6-9b34-4eb2-a5f7-2a82fb40135f/time-out-after-230-seconds?forum=windowsazurewebsitespreview" target="_blank">this thread post</a>). If you host it yourself expect the limits to be different.</em>
<em>*The default maximum timeout is 30 seconds, but this can be configured. When using the hosted instance the timeout may be further limited by the hosting environment (such as Azure App Service's 230-second limit).</em>
</p>

<p>
Expand Down
6 changes: 6 additions & 0 deletions src/Teapot.Web/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Teapot.Web;
using Teapot.Web.Configuration;
using Teapot.Web.Models;
using Teapot.Web.Models.Unofficial;

Expand All @@ -21,6 +22,11 @@
builder.Services.AddSingleton<TeapotStatusCodeMetadataCollection>();
builder.Services.AddApplicationInsightsTelemetry();

// Configure timeout options
builder.Services.Configure<TimeoutOptions>(builder.Configuration.GetSection(TimeoutOptions.SectionName));
builder.Services.AddSingleton<TimeoutOptions>(provider =>
provider.GetRequiredService<Microsoft.Extensions.Options.IOptions<TimeoutOptions>>().Value);

//builder.Services.AddFairUseRateLimiter();

builder.Services.AddCors();
Expand Down
Loading