Aconteceu com todos nós. Você encomenda um gadget de tecnologia novo e brilhante ou um par de jeans novo online.
O pedido demora e então falha devido a uma falha momentânea na rede. Então você o envia novamente, e o pedido passa dessa vez; sucesso!
No entanto, duas semanas depois, você percebe que seu cartão de crédito foi cobrado duas vezes por engano.
Por que isso aconteceu?
Isso aconteceu porque a API de pedidos da loja não implementou a idempotência da API corretamente.
E esse é o assunto do post de hoje.
Nesta publicação, você aprenderá sobre idempotência de API sem servidor, por que ela é essencial e como implementá-la em um serviço de "pedidos" sem servidor de exemplo com funções AWS Lambda, AWS CDK e AWS Lambda Powertools para Python. Também escreveremos um teste de ponta a ponta para validar se a idempotência de API funciona conforme o esperado.
Índice
O que é Idempotência de API
Chamadas de API podem falhar como resultado de problemas de IO de rede. É por isso que os clientes implementam um mecanismo de nova tentativa. No entanto, em alguns casos, uma nova tentativa pode causar problemas, como no cenário de pedido duplicado que descrevi na seção de introdução.
O lado do servidor pode ter processado a solicitação da API na primeira vez. Essa solicitação pode ter criado vários efeitos colaterais, criado recursos, feito cobranças de cartão de crédito e outras coisas. No entanto, quando é repetida para a segunda, terceira, quarta e assim por diante com a mesma entrada, ela não deve criar nenhum efeito colateral e retornar a mesma resposta que a primeira solicitação para ser idempotente.
Ou para resumir bem, de acordo com este excelente artigo da biblioteca de construtores da Amazon:
Uma operação idempotente é aquela em que uma solicitação pode ser retransmitida ou repetida sem efeitos colaterais adicionais
Ok, então como podemos tornar nossas funções Lambda idempotentes?
A resposta curta é com um cache. A resposta mais longa é descrita abaixo.
Implementação de Idempotência sem Servidor
Hash e cache
Em essência, a idempotência é alcançada por meio da infraestrutura de cache e do SDK de serviço que usa esse cache de forma eficiente.
Implementaremos uma tabela do DynamoDB como um mecanismo de cache para a parte de infraestrutura.
Para o SDK do lado do serviço, usaremos o utilitário AWS Lambda Powertools Idempotency
A chave de idempotência é uma representação hash de todo o evento ou de um subconjunto configurado específico do evento, e os resultados da invocação são serializados em JSON e armazenados na sua camada de armazenamento de persistência - documentação do powertools
Quando sua função Lambda é invocada com um novo evento, calculamos uma chave de idempotência com base nesse evento (ou um subconjunto) e armazenamos a resposta da função na tabela de cache de idempotência como um novo registro de idempotência com o hash como sua chave.
Quando a função Lambda é acionada novamente com o mesmo evento, sua chave de hash reside na tabela de idempotência, e o valor serializado JSON é retornado como uma resposta em vez de executar a lógica de negócios novamente.
Vamos revisar um serviço de exemplo, o serviço "orders" que usei na minha série de blogs de receitas , implementar esse fluxo em um serviço sem servidor de exemplo e entender o que o utilitário de idempotência do AWS Lambda Powertools faz.
O Serviço 'Pedidos'
O serviço 'orders' é um projeto de modelo de serviço serverless que criei no GitHub . Ele ajuda você a começar no domínio serverless com todas as melhores práticas, um pipeline CI/CD funcional e código de infraestrutura CDK.
É um serviço simples: os clientes podem fazer pedidos e comprar muitos itens.
Eles podem fazer um pedido enviando uma carga JSON contendo o nome do cliente e o número de itens que desejam comprar como uma solicitação HTTP POST para o caminho /api/orders.
A API GW aciona uma função do AWS Lambda que armazena todos os pedidos em uma tabela do Amazon DynamoDB (o banco de dados Orders) e retorna uma resposta JSON contendo o ID exclusivo do pedido, o nome do cliente e a quantidade de itens comprados.
Agora, vamos tornar essa API sem servidor idempotente.
Nosso objetivo é garantir que, quando os clientes tentarem novamente a mesma solicitação de pedido, eles recebam a mesma resposta e que nenhum novo pedido seja gravado no banco de dados de pedidos.
Design de Idempotência de Serviço de Pedidos
Vamos revisar um caso de uso em que um cliente envia uma solicitação HTTP POST (representando um novo pedido do cliente) para nosso serviço; o serviço cria o pedido e o salva no banco de dados.
Então, a resposta do API GW é perdida devido a problemas de E/S de rede.
Acreditando que o pedido ainda precisa ser criado, o cliente envia uma nova solicitação, com o mesmo payload novamente.
Vamos ver como nossa solução se comporta em cada uma das solicitações.
Primeiro pedido
Observe que o código da camada de idempotência é executado antes do código de lógica de negócios da função do Lambda e é executado novamente após sua conclusão.
A ordem dos eventos:
O usuário envia uma solicitação HTTP POST para /api/orders.
O Amazon API GW aciona nossa função Lambda.
A camada de idempotência faz o hash da carga útil da solicitação e adiciona um novo registro à tabela de idempotência com um estado 'EM ANDAMENTO'.
A função Lambda executa o código de lógica de negócios, salva o novo pedido nas tabelas DynamoDB e Orders DB e retorna uma resposta de objeto JSON.
A camada de idempotência recebe a resposta do objeto JSON do manipulador, atualiza o estado do registro de idempotência para 'COMPLETO', salva a resposta do objeto JSON no banco de dados de idempotência e a retorna ao cliente.
O que acabou de acontecer?
A função Lambda manipula a solicitação de pedido com sucesso, e um novo pedido é inserido no banco de dados Orders. Além disso, a camada de idempotência adicionou um novo registro ao IDEMPOTÊNCIA DB. A chave do registro é um valor hash do evento de entrada do pedido, e seu valor é a resposta JSON do código de lógica de negócios que foi enviada de volta ao usuário como uma resposta à sua chamada de API.
Este registro terá tempo para sair (TTL) definido, pois o cliente pode querer criar um novo pedido com uma carga exata no futuro e queremos permitir isso.
Leia aqui sobre o prazo de validade.
Se quiser saber mais sobre o registro de idempotência e seus estados, vá para a documentação do AWS Lambda Powertools. O registro pode mudar de estado ou até mesmo ser excluído, dependendo de timeouts de função ou erros e exceções não tratados que podem ocorrer durante o código de lógica de negócios.
Fluxo de repetição
Devido a problemas momentâneos de rede, a resposta à primeira chamada de API foi descartada. Nosso cliente percebe o erro e envia uma solicitação de API de nova tentativa com o payload exato.
A ordem dos eventos:
O cliente tenta novamente a chamada da API e envia uma solicitação HTTP POST para /api/orders com a mesma carga útil da primeira solicitação.
O Amazon API GW aciona nossa função Lambda.
A camada de idempotência faz o hash da carga útil da solicitação, encontra um registro de idempotência correspondente na tabela de banco de dados de idempotência com um estado 'COMPLETO' e verifica se ele ainda não expirou.
A camada de idempotência serializa o valor JSON do registro de idempotência e o retorna como a resposta HTTP. O código de lógica de domínio de negócios do manipulador não é executado.
O que acabou de acontecer?
A função Lambda não executou o código comercial. Ela retornou a mesma resposta da primeira chamada de API. Além disso, ela não causou nenhum efeito colateral novo, como um novo pedido sendo salvo no Orders DB.
Como você pode ver no diagrama, não houve interação com o banco de dados de pedidos neste fluxo.
Nossa API agora é idempotente, como esperado.
Entretanto, quando o registro de idempotência atinge seu tempo TTL, a camada de idempotência o removerá e tratará qualquer solicitação de correspondência de hash como uma nova ordem válida para criação.
Como definimos o TTL, então? Isso é com você; eu o definirei para cerca de 5 minutos.
No entanto, se você quiser ainda mais ajustes para descobrir se uma carga útil é correspondida e considerar cabeçalhos de entrada extras, confira a documentação oficial aqui .
Código CDK
Vamos criar a tabela de idempotência dedicada do DynamoDB que serve como nosso cache e fornecer à nossa função lambda as permissões necessárias.
As linhas 10-18 definem a tabela. Observe que a chave primária é definida como 'id' na linha 13 e que habilitamos o recurso TTL na linha 16.
Nas linhas 19-20, concedemos à nossa função lambda as permissões necessárias.
O arquivo CDK do projeto pode ser encontrado aqui (pode ser diferente do código de exemplo aqui).
Código de função Lambda
Há duas maneiras de adicionar idempotência ao código da função Lambda:
Adicione um decorador de idempotência ao manipulador.
Adicione um decorador de idempotência a uma função interna.
Vamos revisar a primeira implementação e depois compará-la com a segunda e decidir qual é melhor.
Criamos a camada de idempotência fornecendo o nome da tabela do DynamoDB e inicializando o objeto de configuração.
Na linha 3, fornecemos o nome da tabela para a camada idempotency DynamoDB do AWS Lambda Powertools. Observe que no GitHub, troquei o nome da tabela codificado para uma variável de ambiente.
Nas linhas 4 a 7, definimos a configuração da camada de idempotência e o TTL para 5 minutos.
Na linha 6, informamos à camada de idempotência onde encontrar a carga útil que queremos fazer hash e usamos um recurso especial do Powertools em conjunto com o JMESpath .
Resumindo, dizemos à camada de idempotência como fazer o hash do nosso registro de idempotência:
Veja o parâmetro do dicionário 'event' de entrada do Lambda para a chave 'body'.
Para eventos do API GW, o parâmetro body é sempre uma string codificada em JSON e você precisa serializá-la em um dicionário.
Após serializar, procure por duas chaves: 'customer_name' e 'order_item_count' e use-as para o hash de idempotência.
Decore o manipulador
Agora vamos dar uma olhada no código da função do manipulador abaixo:
Tudo o que precisamos fazer é adicionar o decorador na linha 18 e fornecer a ele a camada e a configuração que definimos.
Vamos rever os prós e contras desse método.
Prós
Como você pode ver, o uso é simples, o que é seu maior ponto forte.
Contras
Quando a segunda solicitação aciona a função, tudo é manipulado dentro da camada de idempotência na linha 18. As linhas 20-38 nunca são executadas nesse caso de uso. Agora, isso é crucial para entender. Se você escrever sua lógica de autenticação e autorização no início do manipulador, ela será IGNORADA em um caso de uso de correspondência de idempotência. O código do manipulador não é executado. Executar sua lógica de autenticação e autorização antes que o manipulador seja acionado está ok, ou seja, em um autorizador lambda ou um autorizador IAM.
No entanto, você pode ter um problema de segurança grave se esse não for o caso. Outro cliente pode enviar o mesmo payload e obter a resposta do primeiro cliente, já que não estamos levando a autenticação em consideração ao calcular o hash de idempotência; nada bom; acabamos de violar o isolamento do locatário e expusemos as informações confidenciais do nosso usuário a outro usuário ou invasor!
Felizmente, há uma maneira interna de garantir que isso não aconteça, e você pode ajustar o mecanismo de hash de idempotência para considerar mais cabeçalhos e campos para que essa violação de segurança não ocorra. Leia mais sobre isso aqui .
Outra área que pode ser melhorada é que, se você tiver vários campos de carga útil, a declaração do decorador ficará longa e confusa rapidamente.
Decore uma função interna
A segunda opção que temos é decorar uma função interna. Eu sugiro que você decore a função de entrada para a camada lógica, que o manipulador chama para manipular o evento uma vez que ele tenha passado pela validação de entrada, autenticação e autorização.
Se precisar de esclarecimentos sobre o que é uma camada lógica em uma função Lambda, confira minha postagem no blog .
Precisamos alterar nossa configuração de idempotência antes de decorar a função de entrada da camada lógica. Não precisamos mais definir 'event_key_jmespath', pois ele será definido no próprio decorador de função. O arquivo pode ser encontrado aqui .
No serviço 'orders', a função 'handle_create_request' é a função de entrada para a camada lógica. Ela é responsável por adicionar o pedido ao banco de dados através da camada de acesso a dados.
Vamos torná-lo idempotente:
Nas linhas 14-19, adicionamos o decorador de idempotência, definimos os parâmetros de camada e configuração e definimos o parâmetro 'data'_keyword_argument'. Este parâmetro informa à camada de idempotência como construir o registro de idempotência e gerar o hash. Neste caso, fornecemos a ela a classe ' CreateOrderRequest '. Esta classe contém o nome do cliente e o número de itens que ele deseja comprar. A camada de idempotência tem suporte integrado para classes Pydantic, o que é um toque agradável para valores de entrada e retorno. Usamos a classe serializadora 'PydanticSerializer' para informar ao decorador de idempotência que o valor de retorno esperado de 'handle_create_request' é uma classe de modelo Pydantic, 'CreateOrderOutput', que suporta serialização em um dicionário. Este é um novo recurso que foi adicionado na versão 2.24.0 do powertools .
Prós
Aceita modelos Pydantic como valores de hash que permitem fazer hash de vários parâmetros com uma classe.
Simples de usar.
Ele permite mais flexibilidade, pois você controla onde o recurso de idempotência entra em ação.
Evita possíveis violações de segurança e isolamento de locatários quando colocado na função de entrada da camada lógica.
Contras
A linha 23 (register_lambda_context) é uma lógica de idempotência interna e eu gostaria de não ter que escrevê-la, não é tão elegante; tive que adicioná-la de acordo com a documentação .
Agora que entendemos todas as implementações, vamos ver como testá-las.
Teste de ponta a ponta
Vamos escrever um teste de ponta a ponta que acione nosso API Gateway e verifique se temos uma API idempotente.
Na linha 18, criamos o payload HTTP JSON da solicitação de criação de pedido.
Na linha 20, enviamos a solicitação para o endereço HTTP da API GW.
Nas linhas 21 a 25, afirmamos que a solicitação foi bem-sucedida e que o esquema de resposta corresponde aos valores esperados, como o nome do cliente e o número de itens que enviamos na solicitação.
Agora vem a verificação de idempotência.
Na linha 28, salvamos o id do pedido que obtivemos da primeira resposta. Este é o id do pedido que é salvo na tabela orders DB. Queremos garantir que, quando tentarmos novamente a API, já que o TTL não passou, o registro de idempotência deve residir na tabela idempotência. Devemos obter a mesma resposta da primeira solicitação.
Na linha 32, verificamos se os ids de ordem correspondem. Se a camada de idempotência for quebrada, um id de ordem diferente é retornado.
O teste E2E completo pode ser encontrado aqui .
Minha recomendação
Então, qual implementação você deve escolher? Handler ou inner function?
Use a implementação de decoração do manipulador mais direta se você manipular sua autenticação e autorização antes do código do manipulador lambda e da camada de idempotência.
Se esse não for o caso, você deve sempre usar o decorador de função e decorar o ponto de entrada para a camada lógica depois que o código do manipulador passar pela autenticação e autorização e registrar a solicitação.