Featured image of post Solicitações de tempo limite no ASP.NET Core com tokens de cancelamento

Solicitações de tempo limite no ASP.NET Core com tokens de cancelamento

Como definir tempos limite de solicitação no lado do servidor e escrever testes de integração para validá-los

Introdução Link to this section

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 Link to this section

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class TimeoutCancellationMiddleware
{
    private readonly RequestDelegate _next;
    private readonly TimeSpan _timeout;

    public TimeoutCancellationMiddleware(RequestDelegate next, TimeoutCancellationMiddlewareOptions options)
    {
        _next = next;
        _timeout = options.Timeout;
    }

    public async Task InvokeAsync(HttpContext context)
    {
        //Cria um novo CancellationTokenSource e define um tempo limite
        using var timeoutCancellationTokenSource = new CancellationTokenSource();
        timeoutCancellationTokenSource.CancelAfter(_timeout);

        //Cria um novo CancellationTokenSource vinculando o timeoutCancellationToken e o CancellationToken RequestAborted do ASP.NET
        using var combinedCancellationTokenSource =
            CancellationTokenSource
                .CreateLinkedTokenSource(timeoutCancellationTokenSource.Token, context.RequestAborted);

        //Substitui o CancellationToken RequestAborted pelo nosso CancellationToken combinado
        context.RequestAborted = combinedCancellationTokenSource.Token;

        await _next(context);
    }
}

public class TimeoutCancellationMiddlewareOptions
{
    public TimeSpan Timeout { get; set; }

    public TimeoutCancellationMiddlewareOptions(TimeSpan timeout)
    {
        Timeout = timeout;
    }
}

Também podemos criar um método de extensão para facilitar a configuração do middleware:

1
2
3
4
5
6
7
8
9
public static class TimeoutCancellationMiddlewareExtensions
{
    public static IApplicationBuilder UseTimeoutCancellationToken(
        this IApplicationBuilder builder, TimeSpan timeout)
    {
        return builder.UseMiddleware<TimeoutCancellationMiddleware>(
            new TimeoutCancellationMiddlewareOptions(timeout));
    }
}

Em seguida, adicione-o ao pipeline de middleware em nosso aplicativo:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        builder.Services.AddControllers();

        var app = builder.Build();

        ...

        //Configura um tempo limite de solicitação de 10 segundos
        app.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.

DogImageController:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
[ApiController]
[Route("[controller]")]
public class DogImageController : ControllerBase
{
    private readonly IDogApi _dogApi;
    private readonly ILogger<DogImageController> _logger;

    public DogImageController(IDogApi dogApi, ILogger<DogImageController> logger)
    {
        _dogApi = dogApi;
        _logger = logger;
    }

    [HttpGet]
    public async Task<ActionResult<string>> GetAsync(CancellationToken cancellationToken)
    {
        try
        {
            var dog = await _dogApi.GetRandomDog(cancellationToken);

            return dog.message;
        }
        catch(TaskCanceledException ex)
        {
            _logger.LogError(ex, ex.Message);

            return StatusCode(StatusCodes.Status500InternalServerError, "Request timed out or canceled");
        }
    }
}

🚨 Não capture TaskCanceledException em nenhum lugar, exceto no controlador, ou sua API pode retornar um resultado 200 / OK vazio.

DogImageUseCase:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
public class DogImageUseCase: IDogImageUseCase
{
    private readonly IDogApi _dogApi;

    public DogImageUseCase(IDogApi dogApi)
    {
        _dogApi = dogApi;
    }

    public async Task<string> GetRandomDogImage(CancellationToken cancellationToken)
    {
        var dog = await _dogApi.GetRandomDog(cancellationToken);

        return dog.message;
    }
}

Testando a solução Link to this section

Para testar se funciona, vamos forçar um atraso na classe DogImageUseCase:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
public class DogImageUseCase: IDogImageUseCase
{
    private readonly IDogApi _dogApi;

    public DogImageUseCase(IDogApi dogApi)
    {
        _dogApi = dogApi;
    }

    public async Task<string> GetRandomDogImage(CancellationToken cancellationToken)
    {
        var dog = await _dogApi.GetRandomDog(cancellationToken);

        await Task.Delay(TimeSpan.FromMinutes(1), cancellationToken);

        return dog.message;
    }
}

Criando um teste de integração para o cenário de tempo limite Link to this section

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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
public class DogImageTests: IClassFixture<WebApplicationFactory<Program>>
{
    private readonly WebApplicationFactory<Program> _factory;

    public DogImageTests(WebApplicationFactory<Program> factory)
    {
        _factory = factory;
    }

    ...

    [Fact]
    public async Task Timeout_Returns_500WithMessage()
    {
        //Arrange
        var wireMockSvr = WireMockServer.Start();

        var factory = _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 segundos
                builder.UseSetting("HttpClientTimeoutSeconds", "30");

                //Define o tempo limite da solicitação para 5 segundos para que o teste seja executado mais rapidamente
                builder.UseSetting("RequestTimeoutSeconds", "5");  
            });

        var httpClient = factory.CreateClient();

        Fixture fixture = new Fixture();

        var responseObj = fixture.Create<Dog>();
        var responseObjJson = 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 /DogImage
        wireMockSvr.AddGlobalProcessingDelay(TimeSpan.FromSeconds(15));

        //Act
        var apiHttpResponse = await httpClient.GetAsync("/DogImage");

        //Assert
        apiHttpResponse.StatusCode.Should().Be(HttpStatusCode.InternalServerError);

        var responseMessage = await apiHttpResponse.Content.ReadAsStringAsync();

        responseMessage.Should().BeEquivalentTo("Request timed out or canceled");

        wireMockSvr.Stop();
    }
}

Código completo Link to this section

Verifique no Repositório GitHub.

💬 Like or have something to add? Leave a comment below.
Ko-fi
GitHub Sponsor
Licensed under CC BY-NC-SA 4.0
Criado com Hugo
Tema Stack desenvolvido por Jimmy