Um erro imperdoável, na implementação de microsserviços é considerar que a conexão é estável e confiável. Por razões variadas, a rede pode falhar tanto ao enviar uma requisição quanto ao trazer uma resposta.
No lado cliente (microsserviço que está fazendo uma requisição) é fundamental implementar:
- estratégias de proteção ao servidor, como a implementação do padrão Circuit Breaker
- estretégias de repetição de requisição, como a implementação do padrão Retry
No lado servidor (microsserviço que está atendendo uma requisição) é indispensável garantir que todas as requisições sejam idempotentes. Em cenários onde isso não for absolutamente possível, é fundamental garantir que o “estado” do servidor não seja comprometido.
Se tiver interesse em entender mais sobre microsserviços, recomendo que acesse o Guia de Conteúdo para Microsserviços deste site.
O problema com POST
O verbo POST é, por definição, não idempotente.
Considere o seguinte cenário:
MOMENTO 1 – Uma aplicação cliente chama um microsserviço para realizar a inclusão de uma informação. Esta chamada está disponível através de uma operação POST.
MOMENTO 2 – A aplicação servidor recebe a requisição e executa o processamento de forma adequada. Entretanto, a mensagem de resposta acaba se perdendo na rede e, por alguma razão, não chega a aplicação cliente.
MOMENTO 3 – Depois de algum tempo a aplicação cliente identifica um time-out. E, por definição, faz uma nova tentativa de inclusão de informação.
MOMENTO 4 – A aplicação servidor recebe a nova requisição e, inadvertidamente, inclui a mesma informação, uma segunda vez, na base.
Assustador, não acha? Esse é um caso extremamente difícil de detectar e raramente é “pego” em testes de QA.
Uma possível solução
De forma radical, o cenário acima me faz evitar utilizar o verbo post em minhas aplicações. Acabo utilizando um “PUT identificado” (essa solução, aliás vem sendo bastante aplicada estando presente, inclusive em uma aplicação de referência da Microsoft).
[HttpPut] [ProducesResponseType(typeof(string), (int) HttpStatusCode.OK)] [ProducesResponseType((int)HttpStatusCode.BadRequest)] public async Task GetEcho( [FromBody] EchoCommand command, [FromHeader(Name = "x-requestid")] string requestId ) { if (!Guid.TryParse(requestId, out var guid)) { return BadRequest(); } var identifiedCommand = new IdentifiedCommand<EchoCommand, string>( command, guid ); return Ok(await _mediator.Send(identifiedCommand)); }
O caminho que venho adotando é pedir, em operações que mudam o estado do servidor, um identificador da requisição. Este identificador é então ligado ao comando.
A aplicação cliente precisará adicionar um header para identificar a requisição.
Um handler global para comandos identificados checa se o identificador está associado a um comando já executado. Caso esteja, gera um erro.
public class IdentifiedCommandHandler<TCommand, TResult> : IRequestHandler<IdentifiedCommand<TCommand, TResult>, TResult> where TCommand : IRequest { private readonly IMediator _mediator; private readonly IRequestManager _requestManager; public IdentifiedCommandHandler(IMediator mediator, IRequestManager requestManager) { _mediator = mediator; _requestManager = requestManager; } public async Task Handle( IdentifiedCommand<TCommand, TResult> message, CancellationToken cancellationToken = default(CancellationToken) ) { if (message.Id == Guid.Empty) { ThrowMediatrPipelineException.IdentifiedCommandWithoutId(); } if (message.Command == null) { ThrowMediatrPipelineException.IdentifiedCommandWithoutInnerCommand(); } var alreadyRegistered = await _requestManager.IsRegistered(message.Id, cancellationToken); if (alreadyRegistered) { ThrowMediatrPipelineException.CommandWasAlreadyExecuted(); } await _requestManager.Register(message.Id, cancellationToken); var result = await _mediator.Send(message.Command, cancellationToken); return result; } }
Todas as exceptions relacionadas a uma falha por duplicidade são mapeadas adequadamente para um Bad Request.
public class HttpGlobalExceptionFilter : IExceptionFilter { private readonly IHostingEnvironment _env; private readonly ILogger _logger; public HttpGlobalExceptionFilter( IHostingEnvironment env, ILogger logger ) { _env = env; _logger = logger; } public void OnException(ExceptionContext context) { _logger.LogError(new EventId(context.Exception.HResult), context.Exception, context.Exception.Message); if (context.Exception.GetType() == typeof(MediatrPipelineException)) { var validationException = context.Exception.InnerException as ValidationException; if (validationException != null) { var json = new JsonErrorResponse { Messages = validationException.Errors .Select(e => e.ErrorMessage) .ToArray() }; context.Result = new BadRequestObjectResult(json); } context.HttpContext.Response.StatusCode = (int)HttpStatusCode.BadRequest; } else { var json = new JsonErrorResponse { Messages = new[] { "Internal Error. Try again later.", context.Exception.GetType().ToString(), context.Exception.Message } }; context.Result = new ObjectResult(json) { StatusCode = 500 }; context.HttpContext.Response.StatusCode = (int)HttpStatusCode.InternalServerError; } context.ExceptionHandled = true; } public class JsonErrorResponse { public string[] Messages { get; set; } } }
Que geram um Bad Request para o cliente.
Concluindo
A solução perfeita entregaria idempotência. Mas, para isso, eu precisaria armazenar a resposta do servidor e isso poderia conduzir a erros de interpretação. Aqui, opto por deixar claro o que houve de errado.
Mais uma vez, é importante ressaltar que, implementando microsserviços, é fundamental estar preparado para instabilidades de conexão. A questão não é se ocorrerá uma falha, mas quando essa falha irá ocorrer.
O código fonte desse exemplo está disponível no github.
Créditos da imagem da capa para Park Troopers