A todos nos ha pasado: compramos un nuevo y reluciente dispositivo tecnológico o un par de vaqueros nuevos por Internet.
El pedido tarda un poco y luego falla debido a una falla momentánea de la red. Por lo tanto, lo envía nuevamente y esta vez el pedido se procesa. ¡Éxito!
Sin embargo, dos semanas después, te das cuenta de que tu tarjeta de crédito fue cargada dos veces por error.
¿Por qué pasó esto?
Esto ocurrió porque la API de pedidos de la tienda no implementó correctamente la idempotencia de la API.
Y ese es el tema del post de hoy.
En esta publicación, aprenderá sobre la idempotencia de API sin servidor, por qué es esencial y cómo implementarla en un servicio de "pedidos" sin servidor de muestra con funciones de AWS Lambda, AWS CDK y AWS Lambda Powertools para Python. También escribiremos una prueba de extremo a extremo para validar que la idempotencia de API funciona como se espera.
Tabla de contenido
¿Qué es la idempotencia de la API?
Las llamadas a la API pueden fallar como resultado de problemas de E/S de la red. Por eso, los clientes implementan un mecanismo de reintento. Sin embargo, en algunos casos, un reintento puede causar problemas, como en el caso de un pedido duplicado que describí en la sección de introducción.
Es posible que el servidor haya procesado la solicitud de API la primera vez. Esa solicitud puede haber creado numerosos efectos secundarios, recursos, cargos en tarjetas de crédito, etc. Sin embargo, cuando se vuelve a intentar una segunda, tercera, cuarta vez, etc. con la misma entrada, no debe crear ningún efecto secundario y devolver la misma respuesta que la primera solicitud para ser idempotente.
O para resumirlo de forma agradable, según este excelente artículo de la biblioteca de constructores de Amazon:
Una operación idempotente es aquella en la que una solicitud puede retransmitirse o reintentarse sin efectos secundarios adicionales.
Bien, entonces ¿cómo podemos hacer que nuestras funciones Lambda sean idempotentes?
La respuesta corta es con un caché. La respuesta más larga se describe a continuación.
Implementación de idempotencia sin servidor
Hash y caché
En esencia, la idempotencia se logra a través de la infraestructura de caché y el SDK de servicio que utiliza ese caché de manera eficiente.
Implementaremos una tabla DynamoDB como mecanismo de caché para la parte de infraestructura.
Para el SDK del lado del servicio, utilizaremos la utilidad Idempotency de AWS Lambda Powertools.
La clave de idempotencia es una representación hash del evento completo o de un subconjunto específico configurado del evento, y los resultados de la invocación se serializan en JSON y se almacenan en su capa de almacenamiento de persistencia - documentación de powertools
Cuando se invoca su función Lambda con un nuevo evento, calculamos una clave de idempotencia basada en ese evento (o un subconjunto) y almacenamos la respuesta de la función en la tabla de caché de idempotencia como un nuevo registro de idempotencia con el hash como su clave.
Cuando la función Lambda se activa nuevamente con el mismo evento, su clave hash reside en la tabla de idempotencia y el valor serializado JSON se devuelve como respuesta en lugar de ejecutar la lógica empresarial nuevamente.
Repasemos un servicio de muestra, el servicio "pedidos" que he usado en mi serie de blogs de libros de cocina , implementemos este flujo en un servicio sin servidor de muestra y comprendamos lo que hace la utilidad de idempotencia de AWS Lambda Powertools.
El servicio 'Pedidos'
El servicio "pedidos" es un proyecto de plantilla de servicio sin servidor que creé en GitHub . Te ayuda a comenzar en el dominio sin servidor con todas las mejores prácticas, un flujo de trabajo de CI/CD funcional y un código de infraestructura CDK.
Es un servicio sencillo: los clientes pueden realizar pedidos y comprar muchos artículos.
Pueden realizar un pedido enviando una carga JSON que contenga el nombre del cliente y la cantidad de artículos que desean comprar como una solicitud HTTP POST a la ruta /api/orders.
La API GW activa una función AWS Lambda que almacena todos los pedidos en una tabla de Amazon DynamoDB (la base de datos de pedidos) y devuelve una respuesta JSON que contiene el ID de pedido único, el nombre del cliente y la cantidad de artículos comprados.
Ahora, hagamos que esta API sin servidor sea idempotente.
Nuestro objetivo es garantizar que cuando los clientes vuelvan a intentar la misma solicitud de pedido, obtendrán la misma respuesta y no se escribirá ningún pedido nuevo en la base de datos de pedidos.
Pedidos Servicio Idempotencia Diseño
Revisemos un caso de uso donde un cliente envía una solicitud HTTP POST (que representa un nuevo pedido de cliente) a nuestro servicio; el servicio crea el pedido y lo guarda en la base de datos.
Luego, la respuesta de API GW se pierde debido a problemas de E/S de red.
Pensando que aún es necesario crear el pedido, el cliente envía una solicitud de reintento, con la misma carga útil nuevamente.
Veamos cómo se comporta nuestra solución en cada una de las solicitudes.
Primera solicitud
Tenga en cuenta que el código de la capa de idempotencia se ejecuta antes que el código de lógica empresarial de la función Lambda y se ejecuta nuevamente después de finalizar.
El orden de los acontecimientos:
El usuario envía una solicitud HTTP POST a /api/orders.
Amazon API GW activa nuestra función Lambda.
La capa de idempotencia procesa la carga útil de la solicitud y agrega un nuevo registro a la tabla de idempotencia con un estado "EN PROGRESO".
La función Lambda ejecuta el código de lógica empresarial, guarda el nuevo pedido en las tablas DynamoDB y Orders DB, y devuelve una respuesta de objeto JSON.
La capa de idempotencia toma la respuesta del objeto JSON del controlador, actualiza el estado del registro de idempotencia a 'COMPLETO', guarda la respuesta del objeto JSON en la base de datos de idempotencia y la devuelve al cliente.
¿Qué acaba de pasar?
La función Lambda maneja la solicitud de pedido con éxito y se inserta un nuevo pedido en la base de datos de pedidos. Además, la capa de idempotencia agregó un nuevo registro a la base de datos de idempotencia. La clave del registro es un valor hash del evento de pedido de entrada y su valor es la respuesta JSON del código de lógica de negocios que se envió al usuario como respuesta a su llamada API.
Este registro tendrá tiempo de dejarse (TTL) establecido, ya que el cliente podría querer crear un nuevo pedido con una carga útil exacta en el futuro y queremos permitirlo.
Lea aquí sobre el tiempo de expiración.
Si desea obtener más información sobre el registro de idempotencia y sus estados, consulte la documentación de AWS Lambda Powertools. El registro puede cambiar de estado o incluso eliminarse, según los tiempos de espera de las funciones o los errores y excepciones no controlados que puedan ocurrir durante el código de lógica empresarial.
Flujo de reintento
Debido a problemas momentáneos en la red, se perdió la respuesta a la primera llamada a la API. Nuestro cliente detecta el error y vuelve a enviar una solicitud a la API con la misma carga útil.
El orden de los acontecimientos:
El cliente vuelve a intentar la llamada API y envía una solicitud HTTP POST a /api/orders con la misma carga útil que la primera solicitud.
Amazon API GW activa nuestra función Lambda.
La capa de idempotencia procesa la carga útil de la solicitud, encuentra un registro de idempotencia coincidente en la tabla de base de datos de idempotencia con un estado "COMPLETO" y ve que aún no ha expirado.
La capa de idempotencia serializa el valor JSON del registro de idempotencia y lo devuelve como respuesta HTTP. El código de lógica del dominio empresarial del controlador no se ejecuta.
¿Qué acaba de pasar?
La función Lambda no ejecutó el código comercial. Devolvió la misma respuesta que en la primera llamada a la API. Además, no provocó ningún efecto secundario nuevo, como que se guardara un nuevo pedido en la base de datos de pedidos.
Como puede ver en el diagrama, no hubo interacción con la base de datos de pedidos en este flujo.
Nuestra API ahora es idempotente, como se esperaba.
Sin embargo, una vez que el registro de idempotencia alcanza su tiempo TTL, la capa de idempotencia lo eliminará y tratará cualquier solicitud de coincidencia de hash como una nueva orden válida para crear.
¿Cómo configuramos el TTL? Eso depende de ti. Yo lo configuraré en unos 5 minutos.
Sin embargo, si desea realizar ajustes aún más precisos para determinar si una carga útil coincide y considerar encabezados de entrada adicionales, consulte la documentación oficial aquí .
Código CDK
Creemos la tabla de idempotencia dedicada de DynamoDB que sirve como nuestro caché y proporcionemos a nuestra función lambda los permisos necesarios.
Las líneas 10 a 18 definen la tabla. Observe que la clave principal está configurada como 'id' en la línea 13 y que habilitamos la función TTL en la línea 16.
En las líneas 19 y 20, otorgamos a nuestro rol de función lambda los permisos necesarios.
El archivo CDK del proyecto se puede encontrar aquí (puede ser diferente del código de muestra aquí).
Código de función Lambda
Hay dos formas de agregar idempotencia al código de la función Lambda:
Añade un decorador de idempotencia al controlador.
Agrega un decorador de idempotencia a una función interna.
Repasemos la primera implementación y luego comparémosla con la segunda y decidamos cuál es mejor.
Creamos la capa de idempotencia proporcionando el nombre de la tabla DynamoDB e inicializando el objeto de configuración.
En la línea 3, proporcionamos el nombre de la tabla a la capa DynamoDB de idempotencia de AWS Lambda Powertools. Tenga en cuenta que en GitHub, cambié el nombre de la tabla codificado de forma rígida por una variable de entorno.
En las líneas 4 a 7, establecemos la configuración de la capa de idempotencia y el TTL en 5 minutos.
En la línea 6, le indicamos a la capa de idempotencia dónde encontrar la carga útil que queremos codificar y usamos una capacidad especial de Powertools junto con JMESpath .
En resumen, le decimos a la capa de idempotencia cómo codificar nuestro registro de idempotencia:
Mire el parámetro del diccionario de entrada 'evento' de Lambda para la clave 'cuerpo'.
Para los eventos de API GW, el parámetro del cuerpo siempre es una cadena codificada en JSON y es necesario serializarla en un diccionario.
Una vez serializado, busque dos claves: 'customer_name' y 'order_item_count', y úselas para el hash de idempotencia.
Decorar el Handler
Ahora echemos un vistazo al código de función del controlador a continuación:
Todo lo que necesitamos hacer es agregar el decorador en la línea 18 y proporcionarle la capa y la configuración que definimos.
Repasemos los pros y contras de este método.
Ventajas
Como puedes ver, el uso es sencillo, lo cual constituye su mayor fortaleza.
Contras
Cuando la segunda solicitud activa la función, todo se maneja dentro de la capa de idempotencia en la línea 18. Las líneas 20 a 38 nunca se ejecutan en ese caso de uso. Ahora bien, esto es fundamental para entender. Si escribe su lógica de autenticación y autorización al comienzo del controlador, se SALTARÁ en un caso de uso de coincidencia de idempotencia. El código del controlador no se ejecuta. Está bien ejecutar su lógica de autenticación y autorización antes de que se active el controlador, es decir, en un autorizador lambda o un autorizador IAM.
Sin embargo, es posible que tenga un problema de seguridad grave si ese no es el caso. Otro cliente podría enviar la misma carga útil y obtener la respuesta del primer cliente, ya que no estamos teniendo en cuenta la autenticación al calcular el hash de idempotencia; no es genial; ¡acabamos de violar el aislamiento del inquilino y expusimos la información confidencial de nuestro usuario a otro usuario o atacante!
Afortunadamente, existe una forma integrada de garantizar que esto no suceda, y se puede ajustar el mecanismo de hash de idempotencia para que tenga en cuenta más encabezados y campos, de modo que no se produzca esta violación de seguridad. Lea más sobre esto aquí .
Otra área de mejora es que si tienes varios campos de carga útil, la declaración del decorador se vuelve larga y desordenada rápidamente.
Decorar una función interior
La segunda opción que tenemos es decorar una función interna. Te sugiero que decores la función de entrada a la capa lógica, a la que el manejador llama para manejar el evento una vez que ha pasado la validación de entrada, la autenticación y la autorización.
Si necesita una aclaración sobre qué es una capa lógica en una función Lambda, consulte la publicación de mi blog .
Necesitamos modificar nuestra configuración de idempotencia antes de decorar la función de entrada de la capa lógica. Ya no necesitamos configurar 'event_key_jmespath' ya que se definirá en el decorador de funciones. El archivo se puede encontrar aquí .
En el servicio 'pedidos', la función 'handle_create_request' es la función de entrada a la capa lógica. Se encarga de añadir el pedido a la base de datos a través de la capa de acceso a datos.
Hagámoslo idempotente:
En las líneas 14 a 19, agregamos el decorador de idempotencia, establecemos los parámetros de capa y configuración, y establecemos el parámetro 'data'_keyword_argument'. Este parámetro le dice a la capa de idempotencia cómo construir el registro de idempotencia y generar el hash. En este caso, le proporcionamos la clase ' CreateOrderRequest '. Esta clase contiene el nombre del cliente y la cantidad de artículos que desea comprar. La capa de idempotencia tiene soporte integrado para clases de Pydantic, lo cual es un buen detalle tanto para los valores de entrada como de retorno. Usamos la clase serializadora 'PydanticSerializer' para decirle al decorador de idempotencia que el valor de retorno esperado de 'handle_create_request' es una clase de modelo de Pydantic, 'CreateOrderOutput', que admite la serialización en un diccionario. Esta es una nueva característica que se agregó en la versión 2.24.0 de powertools .
Ventajas
Acepta modelos Pydantic como valores hash, lo que permite realizar hash de múltiples parámetros con una clase.
Fácil de usar.
Permite mayor flexibilidad ya que usted controla dónde se activa la función de idempotencia.
Previene posibles violaciones de seguridad y aislamiento de inquilinos cuando se coloca en la función de entrada de la capa lógica.
Contras
La línea 23 (register_lambda_context) es lógica de idempotencia interna y desearía no tener que escribirla, no es tan elegante; tuve que agregarla de acuerdo con la documentación .
Ahora que entendemos todas las implementaciones, veamos cómo las probamos.
Prueba de extremo a extremo
Escribamos una prueba de extremo a extremo que active nuestra API Gateway y verifique que tenemos una API idempotente.
En la línea 18, creamos la carga útil HTTP JSON de la solicitud de creación de orden.
En la línea 20, enviamos la solicitud a la dirección HTTP de la API GW.
En las líneas 21 a 25, afirmamos que la solicitud fue exitosa y que el esquema de respuesta coincide con nuestros valores esperados, como el nombre del cliente y la cantidad de artículos que enviamos en la solicitud.
Ahora viene la comprobación de idempotencia.
En la línea 28, guardamos el ID de pedido que obtuvimos de la primera respuesta. Este es el ID de pedido que se guarda en la tabla de la base de datos de pedidos. Queremos asegurarnos de que cuando volvamos a intentar la API, dado que el TTL no ha pasado, el registro de idempotencia debe residir en la tabla de idempotencia. Deberíamos obtener la misma respuesta que en la primera solicitud.
En la línea 32, verificamos que los identificadores de pedido coincidan. Si la capa de idempotencia está rota, se devuelve un identificador de pedido diferente.
La prueba E2E completa se puede encontrar aquí .
Mi recomendación
Entonces, ¿qué implementación debería elegir? ¿Un controlador o una función interna?
Utilice la implementación de decoración del controlador más sencilla si maneja su autenticación y autorización antes del código del controlador lambda y la capa de idempotencia.
Si ese no es el caso, siempre debe usar la función decoradora y decorar el punto de entrada a la capa lógica después de que su código de controlador pase por la autenticación y autorización y registre la solicitud.
Kommentare