Como definir tempos limite de solicitação no lado do servidor e escrever testes de integração para validá-los
Introdução
Quando o ASP.NET Core está sendo executado em um AWS Lambda e recebendo solicitações através de um AWS API Gateway, o aplicativo não é notificado de um tempo limite do API Gateway e continua processando a solicitação, concluindo-a eventualmente. Isso deixará métricas e logs de uma solicitação bem-sucedida quando o cliente recebeu um erro de tempo limite.
Neste post, mostrarei como resolver este problema com tokens de cancelamento e tempos limite.
A solução
A ideia é definir o tempo limite da solicitação no aplicativo ASP.NET antes do tempo limite do API Gateway. Desta forma, podemos coletar logs e métricas das solicitações que atingiram o tempo limite e ter uma visão realista dos erros que os usuários estão vendo.
Neste post anterior, expliquei como cancelar solicitações abortadas pelo cliente HTTP usando o CancellationToken nos métodos dos controladores.
O model binding padrão do ASP.NET para o CancellationToken injeta a propriedade RequestAborted do objeto HttpContext nos métodos do controlador. A propriedade RequestAborted contém um CancellationToken que é acionado quando o cliente abandona a solicitação.
Vamos criar um middleware para aplicar o tempo limite ao Cancellation Token do ASP.NET Core.
Primeiro, criaremos um novo CancellationTokenSource e definiremos um tempo limite para ele.
Em seguida, usaremos o método CancellationToken.CreateLinkedTokenSource para vincular o CancellationToken HttpContext.RequestAborted ao novo CancellationToken que criamos com um tempo limite.
Por último, substituímos o Cancellation Token HttpContext.RequestAborted pelo token retornado pelo CreateLinkedTokenSource.
⚠️ Lembre-se de sempre descartar os objetos CancellationTokenSource para evitar vazamentos de memória. Use a palavra-chave using ou descarte-os no delegado HttpContext.Response.OnCompleted.
publicclassTimeoutCancellationMiddleware{privatereadonlyRequestDelegate_next;privatereadonlyTimeSpan_timeout;publicTimeoutCancellationMiddleware(RequestDelegatenext,TimeoutCancellationMiddlewareOptionsoptions){_next=next;_timeout=options.Timeout;}publicasyncTaskInvokeAsync(HttpContextcontext){//Cria um novo CancellationTokenSource e define um tempo limiteusingvartimeoutCancellationTokenSource=newCancellationTokenSource();timeoutCancellationTokenSource.CancelAfter(_timeout);//Cria um novo CancellationTokenSource vinculando o timeoutCancellationToken e o CancellationToken RequestAborted do ASP.NETusingvarcombinedCancellationTokenSource=CancellationTokenSource.CreateLinkedTokenSource(timeoutCancellationTokenSource.Token,context.RequestAborted);//Substitui o CancellationToken RequestAborted pelo nosso CancellationToken combinadocontext.RequestAborted=combinedCancellationTokenSource.Token;await_next(context);}}publicclassTimeoutCancellationMiddlewareOptions{publicTimeSpanTimeout{get;set;}publicTimeoutCancellationMiddlewareOptions(TimeSpantimeout){Timeout=timeout;}}
Também podemos criar um método de extensão para facilitar a configuração do middleware:
publicclassProgram{publicstaticvoidMain(string[]args){varbuilder=WebApplication.CreateBuilder(args);builder.Services.AddControllers();varapp=builder.Build();...//Configura um tempo limite de solicitação de 10 segundosapp.UseTimeoutCancellationToken(TimeSpan.FromSeconds(10));...app.Run();}}
Agora, temos que usar o CancellationToken injetado nos métodos dos controladores e passá-lo para todos os métodos assíncronos.
[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(TaskCanceledExceptionex){_logger.LogError(ex,ex.Message);returnStatusCode(StatusCodes.Status500InternalServerError,"Request timed out or canceled");}}}
🚨 Não captureTaskCanceledException em nenhum lugar, exceto no controlador, ou sua API pode retornar um resultado 200 / OK vazio.
Criando um teste de integração para o cenário de tempo limite
Neste post anterior, expliquei como usar WireMock.Net para simular dependências de API em testes de integração.
Agora, usaremos WireMock.Net para criar um mock que responde com um atraso. Desta forma, podemos validar se nosso aplicativo está parando o processamento da solicitação quando o tempo limite ocorre.
Primeiro, substituímos o valor de configuração HttpClientTimeoutSeconds para um tempo limite maior que o tempo limite da nossa solicitação. Esta configuração é usada para definir a propriedade Timeout do HttpClient. Se o tempo limite do HttpClient for menor que o tempo limite da nossa solicitação, ele abortará a solicitação para as dependências antes que o aplicativo atinja o tempo limite.
Em seguida, usamos o AddGlobalProcessingDelay do WireMock para inserir um atraso no mock da API e forçar nosso aplicativo a atingir o tempo limite antes da resposta.
Por último, afirmamos que nosso aplicativo retornou o código de status 500 com a mensagem 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);//Define o tempo limite do HttpClient para 30, para que não seja acionado antes do nosso tempo limite de solicitação que é de 10 segundosbuilder.UseSetting("HttpClientTimeoutSeconds","30");//Define o tempo limite da solicitação para 5 segundos para que o teste seja executado mais rapidamentebuilder.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));//Adiciona um atraso à resposta para causar um tempo limite de solicitação no endpoint /DogImagewireMockSvr.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();}}