How to set request time-outs on the server side and write integration tests to validate them
Introduction
When ASP.NET Core is running in an AWS Lambda and receiving requests through an AWS API Gateway, the application is not notified of an API Gateway time-out and keeps processing the request, completing it eventually. This will leave metrics and logs of a successful request when the client received a time-out error.
In this post, I’ll show how to solve this problem with cancellation tokens and time-outs.
The solution
The idea is to time-out the request in the ASP.NET application before the API Gateway time-out. This way we can collect logs and metrics of the requests that timed out and have a realistic view of the errors the users are seeing.
In this previous post, I’ve explained how to cancel requests aborted by the HTTP client using the CancellationToken in the controllers methods.
The default ASP.NET’s model binding for the CancellationToken injects the RequestAborted property of the HttpContext object in the controller methods. The RequestAborted property holds a CancellationToken that is triggered when the client abandons the request.
Let’s create a middleware to apply the timeout to ASP.NET Core’s Cancellation Token.
First, we’ll create a new CancellationTokenSource and set a time-out to it.
Then, we’ll use the CancellationToken.CreateLinkedTokenSource method to link the HttpContext.RequestAborted CancellationToken to the new CancellationToken we created with a time-out.
Lastly, we override the HttpContext.RequestAborted Cancellation Token with the token returned by the CreateLinkedTokenSource.
⚠️ Remember to always dispose of the CancellationTokenSource objects to avoid memory leaks. Use the using keyword or dispose of them in the HttpContext.Response.OnCompleted delegate.
publicclassTimeoutCancellationMiddleware{privatereadonlyRequestDelegate_next;privatereadonlyTimeSpan_timeout;publicTimeoutCancellationMiddleware(RequestDelegatenext,TimeoutCancellationMiddlewareOptionsoptions){_next=next;_timeout=options.Timeout;}publicasyncTaskInvokeAsync(HttpContextcontext){//Create a new CancellationTokenSource and set a time-outusingvartimeoutCancellationTokenSource=newCancellationTokenSource();timeoutCancellationTokenSource.CancelAfter(_timeout);//Create a new CancellationTokenSource linking the timeoutCancellationToken and ASP.NET's RequestAborted CancellationTokenusingvarcombinedCancellationTokenSource=CancellationTokenSource.CreateLinkedTokenSource(timeoutCancellationTokenSource.Token,context.RequestAborted);//Override the RequestAborted CancellationToken with our combined CancellationTokencontext.RequestAborted=combinedCancellationTokenSource.Token;await_next(context);}}publicclassTimeoutCancellationMiddlewareOptions{publicTimeSpanTimeout{get;set;}publicTimeoutCancellationMiddlewareOptions(TimeSpantimeout){Timeout=timeout;}}
We can also create an extension method to make it easier to configure the middleware:
publicclassProgram{publicstaticvoidMain(string[]args){varbuilder=WebApplication.CreateBuilder(args);builder.Services.AddControllers();varapp=builder.Build();...//Configure a request time-out of 10 secondsapp.UseTimeoutCancellationToken(TimeSpan.FromSeconds(10));...app.Run();}}
Now, we have to use the CancellationToken injected in the controllers’ methods and pass it down to all async methods.
[ApiController][Route("[controller]")]
publicclassDogImageController:ControllerBase{privatereadonlyIDogApi_dogApi;privatereadonlyILogger<DogImageController>_logger;publicDogImageController(IDogApidogApi,ILogger<DogImageController>logger){_dogApi=dogApi;_logger=logger;} [HttpGet]publicasyncTask<ActionResult<string>>GetAsync(CancellationTokencancellationToken){try{vardog=await_dogApi.GetRandomDog(cancellationToken);returndog.message;}catch(TaskCanceledException){_logger.LogError(ex,ex.Message);returnStatusCode(StatusCodes.Status500InternalServerError,"Request timed out or canceled");}}}
🚨 Do not catchTaskCanceledException anywhere except in the controller, or else your API can return an empty 200 / OK result.
Creating an integration test for the time-out scenario
In this previous post, I explained how to use WireMock.Net to mock API dependencies in integration tests.
Now, we’ll use WireMock.Net to create a mock that responds with a delay. This way, we can validate that our application is stopping the processing of the request when the time-out occurs.
First, we override the HttpClientTimeoutSeconds configuration value to a time-out bigger than our request time-out. This configuration is used to set the Timeout property of the HttpClient. If the HttpClient time-out is smaller than our request time-out, it will abort the request to the dependencies before the application times out.
Then, we use WireMock’s AddGlobalProcessingDelay to insert a delay in the API mock and force our application to time-out before the response.
Lastly, we assert that our application returned the status code 500 with the message Request timed out or canceled.
publicclassDogImageTests:IClassFixture<WebApplicationFactory<Program>>{privatereadonlyWebApplicationFactory<Program>_factory;publicDogImageTests(WebApplicationFactory<Program>factory){_factory=factory;}... [Fact]publicasyncTaskTimeout_Returns_500WithMessage(){//ArrangevarwireMockSvr=WireMockServer.Start();varfactory=_factory.WithWebHostBuilder(builder=>{builder.UseSetting("DogApiUrl",wireMockSvr.Url);//Set the HttpClient timeout to 30, so it doesn't trigger before our request timeout that is 10 secondsbuilder.UseSetting("HttpClientTimeoutSeconds","30");//Set the Request time-out to 5 seconds for the test to run fasterbuilder.UseSetting("RequestTimeoutSeconds","5");});varhttpClient=factory.CreateClient();Fixturefixture=newFixture();varresponseObj=fixture.Create<Dog>();varresponseObjJson=JsonSerializer.Serialize(responseObj);wireMockSvr.Given(Request.Create().WithPath("/breeds/image/random").UsingGet()).RespondWith(Response.Create().WithBody(responseObjJson).WithHeader("Content-Type","application/json").WithStatusCode(HttpStatusCode.OK));//Add a delay to the response to cause a request timeout in the /DogImage endpointwireMockSvr.AddGlobalProcessingDelay(TimeSpan.FromSeconds(15));//ActvarapiHttpResponse=awaithttpClient.GetAsync("/DogImage");//AssertapiHttpResponse.StatusCode.Should().Be(HttpStatusCode.InternalServerError);varresponseMessage=awaitapiHttpResponse.Content.ReadAsStringAsync();responseMessage.Should().BeEquivalentTo("Request timed out or canceled");wireMockSvr.Stop();}}