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

Este post tem 9 comentários

  1. Alessandro de Souza

    Elemar, acompanha seus posts desde há muito tempo, e sou grande admirador do seu trabalho.
    Apenas uma dúvida: e se no seu negocio, houvessem regras para que o servidor não executasse requisições repetidas? Já não resolveria o problema sem ter esse custo de infra? Vou dar um exemplo bem básico: o cliente manda um cliente para ser cadastrado no servidor. Se ocorrer o erro exatamente como vc falou e o servidor receber novamente a mesma requisição, não seria só ele procurar pelo CPF, e se já tiver na base, retorna um bad request.
    Apenas um exemplo…

    O que acha?

    1. elemarjr

      O problema é a sinalização para a interface. Se eu implemento apenas a regra de negócio, eu acabaria indicando para o usuário que o cliente que ele está tentando cadastrar já existe (o que não seria verdade para o contexto do usuário). Por outro lado, um indicativo claro de operação repetida dá a oportunidade da interface verificar a consistência do dado gravado e se comportar bem para o usuário.

      Entende?

    2. Gustavo Marques Adolph

      Alessandro,
      Acho que a solução que ele explicou vai mais no sentido de comunicação entre microserviços.

      vamos chamar o serviço que irá fazer a requisição de serviço A e o serviço que irá receber a requisição do serviço A de serviço B.

      A comunicação entre os 2 pontos se da da seguinte forma: o serviço A vai guardar a requisição para tentar novamente caso haja algum problema e o serviço B vai guardar os comandos executados com sucesso.

      Caso haja uma falha na rede na hora da execução e o serviço B conseguir executar o comando, mas não conseguir transmitir ao Serviço A que o comando foi executado. O que irá acontecer é que o Serviço A guardará o retry desse comando e o Serviço B irá guardar que o comando foi executado. Quando o Retry do comando for chamando novamente poderá resultar no problema em questão caso não haja nenhuma tratamento.

      Existem situações onde você não tem uma identificador para verificar se já foi executado um comando, ou caso seja um update por exemplo sua solução não funcionaria, uma vez que o CPF já estaria na base e eu estaria alterando outras informações.

      A solução proposta evita o trabalho de ficar validando a informação no banco de dados, pois valida se o comando foi executado ou não. Facilitando aplicação genérica.

      Podem me corrigir se eu estiver errado, mas acho que seria isso.

      1. Alessandro

        Ah sim… Agora ficou claro.
        Teria algum framework pronto pra tratar desse caso tanto no cliente pra fazer o retry e no servidor? Inclusive com a geração do hash da requisição?

        1. elemarjr

          No cliente eu uso poly. Provavelmente meu próximo Post será sobre isso.

          Para o request, eu uso Guid normal.

  2. Léo

    Muito bom o artigo, recentemente dei uma olhada sobre transação entre microserviços e conheci o padrão Sagas, achei bem interessante e compartilho um video bacana sobre o assunto https://m.youtube.com/watch?v=xDuwrtwYHu8

  3. Alexsandro

    Bacana esta dica ai Elemar, voce pretende também falar sobre Sagas?
    Até onde sei Sagas se encontrada no universo do assuntos sobre Fluxo entre Microsserviços.

Deixe uma resposta