diff --git a/src/Teapot.Web.Tests/IntegrationTests/TimeoutTests.cs b/src/Teapot.Web.Tests/IntegrationTests/TimeoutTests.cs
new file mode 100644
index 0000000..ce7e7aa
--- /dev/null
+++ b/src/Teapot.Web.Tests/IntegrationTests/TimeoutTests.cs
@@ -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().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));
+ }
+}
\ No newline at end of file
diff --git a/src/Teapot.Web.Tests/UnitTests/SleepTests.cs b/src/Teapot.Web.Tests/UnitTests/SleepTests.cs
index 98c4b62..c748a4a 100644
--- a/src/Teapot.Web.Tests/UnitTests/SleepTests.cs
+++ b/src/Teapot.Web.Tests/UnitTests/SleepTests.cs
@@ -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;
@@ -7,6 +9,7 @@ public class SleepTests {
private const int Sleep = 500;
private TeapotStatusCodeMetadataCollection _statusCodes;
+ private TimeoutOptions _timeoutOptions;
[SetUp]
public void Setup() {
@@ -19,13 +22,14 @@ public void Setup() {
new NginxStatusCodeMetadata(),
new TwitterStatusCodeMetadata()
);
+ _timeoutOptions = new TimeoutOptions();
}
[Test]
public void SleepReadFromQuery()
{
Mock 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(() =>
{
@@ -42,7 +46,7 @@ public void SleepReadFromHeader()
Mock 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());
@@ -57,7 +61,7 @@ public void SleepReadFromQSTakesPriorityHeader() {
Mock 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());
@@ -73,7 +77,7 @@ public void BadSleepHeaderIgnored()
Mock 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(() =>
{
@@ -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 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 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 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());
+ }
+
+ [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 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());
+ }
+
+ [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 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());
+ }
+
+ [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 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 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 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 r = (CustomHttpStatusCodeResult)result;
+ Assert.That(r.Options.Sleep, Is.EqualTo(30 * 1000));
+ });
+ }
+
}
diff --git a/src/Teapot.Web.Tests/UnitTests/SuppressBodyTests.cs b/src/Teapot.Web.Tests/UnitTests/SuppressBodyTests.cs
index fb6e504..f0b3543 100644
--- a/src/Teapot.Web.Tests/UnitTests/SuppressBodyTests.cs
+++ b/src/Teapot.Web.Tests/UnitTests/SuppressBodyTests.cs
@@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Http;
+using Teapot.Web.Configuration;
using Teapot.Web.Models;
using Teapot.Web.Models.Unofficial;
@@ -6,6 +7,7 @@ namespace Teapot.Web.Tests.UnitTests;
public class SuppressBodyTests
{
private TeapotStatusCodeMetadataCollection _statusCodes;
+ private TimeoutOptions _timeoutOptions;
[SetUp]
public void Setup()
@@ -19,6 +21,7 @@ public void Setup()
new NginxStatusCodeMetadata(),
new TwitterStatusCodeMetadata()
);
+ _timeoutOptions = new TimeoutOptions();
}
[Test]
@@ -28,7 +31,7 @@ public void Setup()
public void SuppressBodyReadFromQuery(bool? suppressBody)
{
Mock 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(() =>
{
@@ -48,7 +51,7 @@ public void SuppressBodyReadFromHeader(string? suppressBody)
Mock 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(() =>
{
@@ -76,7 +79,7 @@ public void SuppressBodyReadFromQSTakesPriorityHeader(string? headerValue, bool?
Mock 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(() =>
{
@@ -98,7 +101,7 @@ public void BadSuppressBodyHeaderIgnored()
Mock 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(() =>
{
diff --git a/src/Teapot.Web/Configuration/TimeoutOptions.cs b/src/Teapot.Web/Configuration/TimeoutOptions.cs
new file mode 100644
index 0000000..4da1b25
--- /dev/null
+++ b/src/Teapot.Web/Configuration/TimeoutOptions.cs
@@ -0,0 +1,14 @@
+using System.ComponentModel.DataAnnotations;
+
+namespace Teapot.Web.Configuration;
+
+public class TimeoutOptions
+{
+ public const string SectionName = "Timeout";
+
+ ///
+ /// Maximum sleep timeout in milliseconds. Default is 30 seconds (30,000 ms).
+ ///
+ [Range(0, int.MaxValue, ErrorMessage = "MaxSleepMilliseconds must be a non-negative value")]
+ public int MaxSleepMilliseconds { get; set; } = 30 * 1000; // 30 seconds default
+}
\ No newline at end of file
diff --git a/src/Teapot.Web/CustomHttpStatusCodeResult.cs b/src/Teapot.Web/CustomHttpStatusCodeResult.cs
index 988d901..98c1067 100644
--- a/src/Teapot.Web/CustomHttpStatusCodeResult.cs
+++ b/src/Teapot.Web/CustomHttpStatusCodeResult.cs
@@ -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");
@@ -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)
diff --git a/src/Teapot.Web/Pages/Index.cshtml b/src/Teapot.Web/Pages/Index.cshtml
index 96218df..169f0ef 100644
--- a/src/Teapot.Web/Pages/Index.cshtml
+++ b/src/Teapot.Web/Pages/Index.cshtml
@@ -31,10 +31,10 @@
- If you want a delay on the response add a query string or provide a header of @StatusExtensions.SLEEP_HEADER 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 @StatusExtensions.SLEEP_HEADER for the sleep duration (the time in ms, max 30 seconds*), like this:
httpstat.us/200?sleep=5000.
- *When using the hosted instance the timeout is actually 230 seconds, which is the max timeout allowed by an Azure App Service (see this thread post). If you host it yourself expect the limits to be different.
+ *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).
diff --git a/src/Teapot.Web/Program.cs b/src/Teapot.Web/Program.cs
index dbecf5c..fa3cfba 100644
--- a/src/Teapot.Web/Program.cs
+++ b/src/Teapot.Web/Program.cs
@@ -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;
@@ -21,6 +22,11 @@
builder.Services.AddSingleton();
builder.Services.AddApplicationInsightsTelemetry();
+// Configure timeout options
+builder.Services.Configure(builder.Configuration.GetSection(TimeoutOptions.SectionName));
+builder.Services.AddSingleton(provider =>
+ provider.GetRequiredService>().Value);
+
//builder.Services.AddFairUseRateLimiter();
builder.Services.AddCors();
diff --git a/src/Teapot.Web/StatusExtensions.cs b/src/Teapot.Web/StatusExtensions.cs
index 04139f5..459c2c2 100644
--- a/src/Teapot.Web/StatusExtensions.cs
+++ b/src/Teapot.Web/StatusExtensions.cs
@@ -9,6 +9,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using Teapot.Web.Configuration;
using Teapot.Web.Models;
namespace Teapot.Web;
@@ -49,7 +50,8 @@ internal static IResult HandleStatusRequestAsync(
bool? suppressBody,
string? wildcard,
HttpRequest req,
- [FromServices] TeapotStatusCodeMetadataCollection statusCodes
+ [FromServices] TeapotStatusCodeMetadataCollection statusCodes,
+ [FromServices] TimeoutOptions timeoutOptions
)
{
TeapotStatusCodeMetadata statusData = statusCodes.TryGetValue(status, out TeapotStatusCodeMetadata? value) ?
@@ -61,13 +63,14 @@ [FromServices] TeapotStatusCodeMetadataCollection statusCodes
Sleep = sleep,
SuppressBody = suppressBody
};
- return CommonHandleStatusRequestAsync(options, wildcard, req);
+ return CommonHandleStatusRequestAsync(options, wildcard, req, timeoutOptions);
}
internal static IResult CommonHandleStatusRequestAsync(
ResponseOptions options,
string? wildcard,
- HttpRequest req)
+ HttpRequest req,
+ TimeoutOptions timeoutOptions)
{
options.Sleep ??= ParseHeaderInt(req, SLEEP_HEADER);
options.SleepAfterHeaders ??= ParseHeaderInt(req, SLEEP_AFTER_HEADERS);
@@ -83,12 +86,20 @@ internal static IResult CommonHandleStatusRequestAsync(
header => header.Key.Replace(CUSTOM_RESPONSE_HEADER_PREFIX, string.Empty, StringComparison.InvariantCultureIgnoreCase),
header => header.Value);
- return new CustomHttpStatusCodeResult(options);
+ // Check if any sleep values exceed the maximum timeout
+ if ((options.Sleep.HasValue && options.Sleep.Value > timeoutOptions.MaxSleepMilliseconds) ||
+ (options.SleepAfterHeaders.HasValue && options.SleepAfterHeaders.Value > timeoutOptions.MaxSleepMilliseconds))
+ {
+ return TypedResults.BadRequest();
+ }
+
+ return new CustomHttpStatusCodeResult(options, timeoutOptions.MaxSleepMilliseconds);
}
internal static IResult HandleRandomRequest(
HttpRequest req,
[FromServices] TeapotStatusCodeMetadataCollection statusCodes,
+ [FromServices] TimeoutOptions timeoutOptions,
int? sleep,
bool? suppressBody,
string? wildcard,
@@ -102,7 +113,7 @@ internal static IResult HandleRandomRequest(
new TeapotStatusCodeMetadata { Description = $"{status} Unknown Code" };
ResponseOptions options = new(status, statusData);
- return CommonHandleStatusRequestAsync(options, wildcard, req);
+ return CommonHandleStatusRequestAsync(options, wildcard, req, timeoutOptions);
}
catch
{