DEV Community

Cover image for Fundamentos de API Serverless - Idempotência
Eduardo Rabelo
Eduardo Rabelo

Posted on • Edited on

Fundamentos de API Serverless - Idempotência

Quando comecei com desenvolvimento em nuvem, minha equipe e eu mergulhamos de cabeça em todos os aspectos do design de software moderno. Uma das discussões mais divertidas que tivemos foi em torno da idempotência.

Não por causa das discussões acadêmicas que teríamos em torno desse tópico, mas porque nenhum de nós sabia como pronunciá-la. Todos nós andávamos pela sala dizendo de maneiras diferentes e acenamos com a cabeça quando alguém pronunciava de uma maneira que soava certa. Nenhum de nós sabia o que significava, mas pelo menos era divertido dizer.

Quando começamos a tentar entender o que isso significava e como implementar esse conceito em nossas aplicações serverless, foi quando começamos a discordar.

Idempotência, em sua essência soa como um aspecto simples da engenharia de software, refere-se a uma operação que produz o mesmo resultado quando chamada várias vezes.

Mas não é tão simples. Há muitos lados da idempotência que recentemente descobri que muitas pessoas não concordam.

Fiz pesquisas no LinkedIn e no Twitter na semana passada com uma pergunta complicada de idempotência para ver o que a comunidade pensava. A pergunta que fiz foi:

O que você faria em um endpoint idempotente quando uma solicitação duplicada chega enquanto a original ainda está sendo processado?

Isso em si é uma pergunta muito direcionada. Não perguntei sobre o que era idempotência ou sobre qualquer um de seus aspectos principais. Mas eu tenho opiniões elas também.

Senti como se tivesse preparado uma armadilha para coelhos, mas peguei ursos, veados, coelhos, guaxinins e abutres.

Antes de mergulharmos nos detalhes sobre a idempotência e o que está errado ou não, vamos dar uma olhada nos resultados. Recebi 325 respostas com uma distribuição bastante interessante.

O que fazer em chamadas idempotentes simultâneas?

Como você pode ver, tivemos uma mistura de opiniões sobre o que fazer nesse cenário. Isso parece ser devido à ambiguidade sobre o que significa idempotência na indústria . Então, vamos explorar um pouco os vários componentes dele.

Princípios de Idempotência

Se a idempotência fosse tão simples como fazer a mesma coisa toda vez que você executasse uma operação com o mesmo payload, não haveria necessidade deste post. Todos concordariam e seria um conceito fortemente definido. Mas a idempotência tem várias coisas a serem consideradas durante sua implementação, o que a torna difícil.

Efeito no sistema

Quando falamos de idempotência, todos podem concordar unanimemente que chamadas idempotentes têm o mesmo efeito no sistema independente de quantas vezes a operação é chamada para o mesmo payload. Mas o que isso realmente significa? Você poderia seguir caminhos diferentes nessa afirmação.

A operação é inerentemente idempotente - Isso significa que a operação completa pode ser executada sem considerações extras no design ou no código. Um ótimo exemplo disso é uma operação PUT. Um PUT substituirá todos os valores existentes de uma entidade de dados pelo conteúdo da solicitação. É uma substituição estrita de 1 por 1 que simplesmente faz uma substituição. Isso poderia ser chamado uma vez ou 100 vezes e resultaria na entidade de dados permanecendo no mesmo estado.

Alguns consideram uma operação DELETE como idempotente também. Chamar um endpoint para excluir um objeto várias vezes não excluirá o objeto várias vezes. Ele o excluirá uma vez e, em seguida, executará um no-op (nenhuma operação) nas chamadas subsequentes.

No entanto, isso apenas arranha a superfície de um DELETE. Se você levar em consideração os eventos que são acionados quando uma entidade de dados é removida ou os logs de auditoria que são gravados, isso realmente tem o mesmo efeito da primeira vez que foi chamado? Talvez na entidade de dados, mas não no sistema como um todo.

A operação deve ser codificada para idempotência - Costumo pensar nessa categoria para garantir que você não deixe o chamador criar duplicatas acidentalmente. O chamador pode ser um usuário final consumindo sua API ou pode ser um mecanismo automatizado executando uma nova tentativa em um manipulador de eventos com falha.

Um ótimo exemplo disso é a manipulação de pagamentos. Se um chamador fizer várias chamadas acidentalmente para o seu terminal tentando efetuar um pagamento, a última coisa que você deseja fazer é cobrar mais de uma vez. Ao construir sua API de forma idempotente, você garante que o pagamento será processado apenas uma vez.

A maneira mais comum de obter idempotência de uma perspectiva de código é aceitar um cabeçalho idempotency-key em suas solicitações. Se suas operações forem assíncronas e não tiverem cabeçalhos, você poderá adotar uma propriedade idempotency-key no payload ou usar algo como o ID da solicitação (desde que não seja alterado nas novas tentativas).

Use o idempotency-key como um bloqueio e como uma chave de pesquisa para salvar a resposta e retornar o resultado nas chamadas subsequentes.

Resposta ao chamador

É aí que entra muita discussão. Algumas pessoas pensam que para que uma operação seja idempotente, a resposta deve ser idêntica em cada chamada. Outros acreditam que a idempotência para no lado do servidor.

Você pode ver as opiniões divididas nos resultados da pesquisa. Aqueles que acreditam que a resposta deve ser a mesma para o chamador escolheram a opção “Wait For Original To Finish”. Os outros acreditam que a idempotência pode ser alcançada retornando resultados diferentes dependendo do que o sistema está fazendo.

Um bom exemplo disso é o debate sobre DELETE. A exclusão de um recurso normalmente retornará um código de status 204 No Content quando a exclusão for realizada com êxito. Mas e quando você tenta excluir o recurso novamente, seja por acidente ou de propósito? Você ainda retorna um 204 para fornecer uma resposta idempotente ao chamador? Ou você retorna um código de status 404 Not Found ou 410 Gone porque ele não existe?

Retornar um código de status 404 ou 410 resulta no mesmo efeito no sistema (excluindo eventos downstream), então alguns ainda o consideram idempotente.

Para chamadas que usam uma chave de idempotência, temos uma abordagem diferente.

Quando uma chave de idempotência é usada, ela salva um registro, bloqueando a chave fornecida. Uma vez concluído o processamento, ele salva o resultado no registro idempotente e desbloqueia a chave. Se outra solicitação chegar com a mesma chave, ela retornará o resultado salvo no registro.

Diagrama de fluxo de trabalho quando uma chave de idempotência é usada. Fonte: Documentação do AWS Lambda Powertools

Diagrama de fluxo de trabalho quando uma chave de idempotência é usada. Fonte: Documentação do AWS Lambda Powertools

Esse tipo de fluxo é onde a pergunta da minha enquete se originou. Ele estava pedindo a opinião do que você deve fazer quando uma solicitação duplicada chega quando a chave está bloqueada. Como o registro é bloqueado com base na chave, o mesmo efeito ocorrerá no sistema, mas o que você envia de volta ao chamador?

Depois de ler muitos dos comentários nas pesquisas e ter algumas ótimas conversas com Andres Moreno, Kevin Swiber e Matthew Bonig, cheguei à conclusão de retornar imediatamente um sucesso.

Quando postei a enquete originalmente, isso parecia a opção mais distante da correta. Nem foi uma opção na enquete! Mas faz sentido. Se você retornar um código de status 202 Accepted, isso indica ao chamador que um processo está sendo executado no servidor. Opcionalmente, você pode retornar um URL para um endpoint de “obter status” na resposta para que o chamador possa verificar o status por conta própria.

Esperar pelo processamento da atividade é um desperdício de recursos. Você pedirá ao seu aplicativo para aguardar uma resposta apenas para fazer uma chamada parecer completa para o chamador. Com serverless, você está apenas jogando dinheiro fora, forçando uma função Lambda a permanecer viva à espera desse longo processo. Agora que a sustentabilidade é um pilar no Well-Architected Framework, forçar uma espera seria ir contra as melhores práticas da AWS.

Um erro 4XX indica que o chamador fez algo errado. Nesse caso, eles não esperaram tempo suficiente para o processamento terminar, o que não é culpa do chamador. Também não é um erro do lado do servidor (código de status 5XX). O que significa que lançar um erro realmente não se aplica. A última coisa que você deseja é que o chamador tome uma ação corretiva alterando uma solicitação ou enviando a solicitação mais vezes porque recebeu um erro.

As respostas em operações idempotentes diferem com base no estado da solicitação original:

  • Concluído com sucesso - O código de status original e o corpo da resposta são retirados de um cache, como Momento, e retornados ao chamador.
  • Concluída com uma falha - A operação tenta novamente como se fosse a original.
  • Em andamento - Retorna um sucesso e não realiza nenhuma operação.

Tempo de Vida

Como dito anteriormente, uma chave de idempotência salvará um registro para evitar duplicatas. Mas por quanto tempo esse registro viverá?

Se você deixá-lo para sempre, isso significa que nenhuma outra chamada poderá usar essa chave novamente, o que pode ou não ser uma coisa ruim. Isso traz outro ponto positivo.

Você deve sempre validar o payload da solicitação em relação à chave de idempotência.

Vamos dar um exemplo. Neste projeto de referência de arquitetura, os usuários podem adicionar suas cabras no sistema para que possam vincular produtos de cabra (sabão, leite, queijo, etc.) para vender.

Se dois criadores de cabras fossem adicionar suas cabras ao sistema ao mesmo tempo e usarem a mesma chave de idempotência, o que deveria acontecer? Essas não são solicitações duplicadas - que é o que a idempotência está aí para resolver. Em vez disso, são solicitações concorrentes que usam a mesma chave.

Em situações como essa, o sistema deve validar o corpo da solicitação que vem com a chave e verificar se corresponde ao corpo da solicitação original. Esta é a única maneira de (principalmente) garantir que você está evitando duplicatas.

No projeto de referência, geramos um hash do corpo da solicitação e salvamos junto com a chave. Se outra solicitação chegar com a mesma chave que não tenha o mesmo hash, uma solicitação 400 Bad Request será retornada informando que a carga não corresponde à carga da solicitação original.

Se nunca expirarmos as chaves de idempotência, poderíamos nos deparar com colisões como essa desnecessariamente. Você pode forçar o formato de sua chave de idempotência a ser algum tipo de combinação de UUID ou timestamp/hash, mas isso adiciona alguma sobrecarga operacional que pode não valer a pena a longo prazo.

Portanto, ao expirar ou definir um tempo de vida em suas chaves de idempotência, você está liberando esse valor de volta ao conjunto de chaves disponíveis.

Lembre-se de que seu objetivo é evitar entradas duplicadas no sistema, portanto, defina seu tempo de vida para ser um pouco maior do que a duração máxima de novas tentativas. Se você tiver uma estratégia de recuo (backoff) e repetição (retry) automática em um processo assíncrono com falha 50 vezes ao longo de 24 horas, defina seu tempo de vida para 25 horas.

Para chamadas síncronas ou de solicitação/resposta, a duração do tempo de vida útil pode ser significativamente menor. O caso de uso provável para um endpoint de API enviando duplicatas seria um clique duplo acidental em um botão de envio. Neste caso, a detecção de duplicatas aconteceria imediatamente. Para o bem futuro, podemos definir o tempo de vida dessas chamadas para cerca de uma hora para capturar quaisquer solicitações não autorizadas que chegarem depois.

Armazenamento de registros

Cobrimos detalhadamente a necessidade de rastrear e armazenar um registro de idempotência. Este registro tem um padrão de acesso de chave/valor simples e precisa expirar após um curto período de tempo. Também é de extrema importância que a pesquisa seja extremamente rápida para evitar duplicatas no cenário de “clique duplo acidental” ou para eventos que foram entregues várias vezes.

Parece um ótimo caso de uso para armazenamento em cache.

O cache, por definição, é uma camada de dados de alta velocidade que armazena um pequeno conjunto de dados transitórios. Isso é exatamente o que queremos para armazenar registros de idempotência.

Para não parecer purista, mas quando crio aplicativos serverless, prefiro que tudo seja serverless. O uso do Amazon Elasticache quebra esse paradigma. Esse serviço tem preço de pagamento por hora e não tem a flexibilidade que estou acostumado ao trabalhar com serviços serverless.

Em vez disso, optei pelo Momento, que é uma solução de cache completamente serverless. Ele opera sob o mesmo modelo de pagamento pelo que você usa dos serviços serverless da AWS, incluindo um nível gratuito de 50 GB/mês. Como não tem servidor, ele é dimensionado automaticamente para corresponder à quantidade de tráfego e aumenta o tamanho do cache sem se preocupar com nós de dados e instâncias de servidores.

Salvar registros em uma cache ao invés de em um banco de dados ajuda a seguir o princípio de privilégio mínimo. Como não estamos estabelecendo uma conexão com o DynamoDB, podemos omitir as permissões GetItem, PutItem e DeleteItem de todas as funções do Lambda que precisam ser idempotentes porque não estamos gerenciando os registros de idempotência. Isso bloqueia nossas funções apenas para as permissões necessárias para a operação.

Como os dados armazenados em cache devem durar pouco, todos os registros expirarão automaticamente. Esse comportamento pode ser imitado no DynamoDB incluindo um TTL no registro, mas a exclusão de um registro com um TTL não é exata. Pode expirar em até 48 horas após a data de expiração. Ao colocar registros em um cache, você garante que não terá nenhum registro de idempotência por mais tempo do que deveria.

Resumo

A idempotência é um grande tópico que resulta em grandes opiniões no mundo do desenvolvimento. Dependendo da sua definição de idempotente, a maneira como você a implementa pode variar muito.

Dito isto, parece haver um único consenso geral. Não importa como você implemente a idempotência, chamadas duplicadas devem resultar no mesmo impacto no sistema .

A partir daí começamos a divergir. Mas estou feliz em compartilhar minhas opiniões em um esforço para torná-las conhecidas e espero começar a ser consistente em algumas ideias.

  • A idempotência não garante uma resposta idêntica de volta ao chamador, mas é um bom bônus.
  • Chamadas simultâneas com a mesma chave de idempotência devem resultar em uma resposta bem-sucedida e sem operação (no-op) realizada pelo servidor.
  • O tempo de vida das chaves de idempotência deve ser um pouco maior do que o total do seu mecanismo de recuo/repetição (backoff e retry).
  • Operações que consideramos naturalmente idempotentes como DELETE e PUTs podem não ser. Depende de ações que ocorrem em sistema downstreams como resultado de uma mudança.
  • Se possível, use um mecanismo de cache em vez de um banco de dados para registros de idempotência para respostas mais rápidas e expiração de registro no prazo.

Claro que precisamos lembrar que isso é software, e qual é a resposta para a maioria dos problemas de software?

Depende!

Leve seus casos de uso em consideração antes de implementar uma solução. Você pode até ter diferentes estratégias baseadas em diferentes APIs!

Existem pacotes por aí que lidam excepcionalmente bem com a idempotência. Os desenvolvedores de Serverless em Python devem considerar fortemente a biblioteca AWS Lambda Powertools. Ela lida com tudo o que abordamos hoje, além de alguns casos de uso estendidos, como quando a função do Lambda expira. Observe que, no momento da redação deste artigo, ele está disponível apenas em Python. O TypeScript Lambda Powertools não é compatível.

Este post não é totalmente abrangente e tenho certeza que irritou algumas penas. Concordando ou não com minhas opiniões, é bom estar ciente do que os outros acreditam.

Quando você mergulhar em conversas com alguém sobre idempotência, lembre-se de definir primeiro o nível.

Vocês podem não estar falando sobre a mesma coisa.

Se diverta e bom código!

Créditos

Top comments (0)