From cc239ea4aeffc51cfe63fbbb151c33ec49734645 Mon Sep 17 00:00:00 2001 From: David Calavera Date: Mon, 24 Jun 2024 19:57:50 -0700 Subject: [PATCH] Relax blanket implementation of Diagnostic (#897) Instead of implementing Diagnostic for everything that implements Display, implement the trait only for a few well known types. This gives people more flexibility to implement Diagnostic. Signed-off-by: David Calavera --- Cargo.toml | 6 +- .../Cargo.toml | 1 + examples/basic-error-diagnostic/.gitignore | 1 + examples/basic-error-diagnostic/Cargo.toml | 22 +++ examples/basic-error-diagnostic/README.md | 13 ++ examples/basic-error-diagnostic/src/main.rs | 37 +++++ examples/basic-error-handling/README.md | 4 +- examples/basic-error-handling/src/main.rs | 8 +- lambda-events/Cargo.toml | 6 +- lambda-http/src/lib.rs | 3 +- lambda-http/src/streaming.rs | 5 +- lambda-runtime/src/diagnostic.rs | 141 ++++++++++++++++++ lambda-runtime/src/layers/api_response.rs | 11 +- lambda-runtime/src/lib.rs | 10 +- lambda-runtime/src/requests.rs | 3 +- lambda-runtime/src/runtime.rs | 9 +- lambda-runtime/src/types.rs | 81 +--------- 17 files changed, 251 insertions(+), 110 deletions(-) create mode 100644 examples/basic-error-diagnostic/.gitignore create mode 100644 examples/basic-error-diagnostic/Cargo.toml create mode 100644 examples/basic-error-diagnostic/README.md create mode 100644 examples/basic-error-diagnostic/src/main.rs create mode 100644 lambda-runtime/src/diagnostic.rs diff --git a/Cargo.toml b/Cargo.toml index 0b01ee05..c55f0e60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,11 @@ exclude = ["examples"] [workspace.dependencies] base64 = "0.22" bytes = "1" -chrono = "0.4.35" +chrono = { version = "0.4.35", default-features = false, features = [ + "clock", + "serde", + "std", +] } futures = "0.3" futures-channel = "0.3" futures-util = "0.3" diff --git a/examples/advanced-sqs-multiple-functions-shared-data/Cargo.toml b/examples/advanced-sqs-multiple-functions-shared-data/Cargo.toml index 116ab8ef..bd41a01a 100644 --- a/examples/advanced-sqs-multiple-functions-shared-data/Cargo.toml +++ b/examples/advanced-sqs-multiple-functions-shared-data/Cargo.toml @@ -1,4 +1,5 @@ [workspace] +resolver = "2" members = [ "producer", diff --git a/examples/basic-error-diagnostic/.gitignore b/examples/basic-error-diagnostic/.gitignore new file mode 100644 index 00000000..c41cc9e3 --- /dev/null +++ b/examples/basic-error-diagnostic/.gitignore @@ -0,0 +1 @@ +/target \ No newline at end of file diff --git a/examples/basic-error-diagnostic/Cargo.toml b/examples/basic-error-diagnostic/Cargo.toml new file mode 100644 index 00000000..b81ef730 --- /dev/null +++ b/examples/basic-error-diagnostic/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "basic-error-diagnostic" +version = "0.1.0" +edition = "2021" + +# Starting in Rust 1.62 you can use `cargo add` to add dependencies +# to your project. +# +# If you're using an older Rust version, +# download cargo-edit(https://github.com/killercup/cargo-edit#installation) +# to install the `add` subcommand. +# +# Running `cargo add DEPENDENCY_NAME` will +# add the latest version of a dependency to the list, +# and it will keep the alphabetic ordering for you. + +[dependencies] + +lambda_runtime = { path = "../../lambda-runtime" } +serde = "1" +thiserror = "1.0.61" +tokio = { version = "1", features = ["macros"] } diff --git a/examples/basic-error-diagnostic/README.md b/examples/basic-error-diagnostic/README.md new file mode 100644 index 00000000..b9bf1827 --- /dev/null +++ b/examples/basic-error-diagnostic/README.md @@ -0,0 +1,13 @@ +# AWS Lambda Function Error handling example + +This example shows how to implement the `Diagnostic` trait to return a specific `error_type` in the Lambda error response. If you don't use the `error_type` field, you don't need to implement `Diagnostic`, the type will be generated based on the error type name. + +## Build & Deploy + +1. Install [cargo-lambda](https://github.com/cargo-lambda/cargo-lambda#installation) +2. Build the function with `cargo lambda build --release` +3. Deploy the function to AWS Lambda with `cargo lambda deploy --iam-role YOUR_ROLE` + +## Build for ARM 64 + +Build the function with `cargo lambda build --release --arm64` diff --git a/examples/basic-error-diagnostic/src/main.rs b/examples/basic-error-diagnostic/src/main.rs new file mode 100644 index 00000000..11f68d4b --- /dev/null +++ b/examples/basic-error-diagnostic/src/main.rs @@ -0,0 +1,37 @@ +use lambda_runtime::{service_fn, Diagnostic, Error, LambdaEvent}; +use serde::Deserialize; +use thiserror; + +#[derive(Deserialize)] +struct Request {} + +#[derive(Debug, thiserror::Error)] +pub enum ExecutionError { + #[error("transient database error: {0}")] + DatabaseError(String), + #[error("unexpected error: {0}")] + Unexpected(String), +} + +impl<'a> From for Diagnostic<'a> { + fn from(value: ExecutionError) -> Diagnostic<'a> { + let (error_type, error_message) = match value { + ExecutionError::DatabaseError(err) => ("Retryable", err.to_string()), + ExecutionError::Unexpected(err) => ("NonRetryable", err.to_string()), + }; + Diagnostic { + error_type: error_type.into(), + error_message: error_message.into(), + } + } +} + +/// This is the main body for the Lambda function +async fn function_handler(_event: LambdaEvent) -> Result<(), ExecutionError> { + Err(ExecutionError::Unexpected("ooops".to_string())) +} + +#[tokio::main] +async fn main() -> Result<(), Error> { + lambda_runtime::run(service_fn(function_handler)).await +} diff --git a/examples/basic-error-handling/README.md b/examples/basic-error-handling/README.md index 498f8a50..5eef4207 100644 --- a/examples/basic-error-handling/README.md +++ b/examples/basic-error-handling/README.md @@ -1,4 +1,6 @@ -# AWS Lambda Function example +# AWS Lambda Function Error handling example + +This example shows how to return a custom error type for unexpected failures. ## Build & Deploy diff --git a/examples/basic-error-handling/src/main.rs b/examples/basic-error-handling/src/main.rs index 528d6f02..3bc76936 100644 --- a/examples/basic-error-handling/src/main.rs +++ b/examples/basic-error-handling/src/main.rs @@ -1,7 +1,7 @@ /// See https://github.com/awslabs/aws-lambda-rust-runtime for more info on Rust runtime for AWS Lambda use lambda_runtime::{service_fn, tracing, Error, LambdaEvent}; use serde::{Deserialize, Serialize}; -use serde_json::{json, Value}; +use serde_json::json; use std::fs::File; /// A simple Lambda request structure with just one field @@ -59,11 +59,11 @@ async fn main() -> Result<(), Error> { } /// The actual handler of the Lambda request. -pub(crate) async fn func(event: LambdaEvent) -> Result { +pub(crate) async fn func(event: LambdaEvent) -> Result { let (event, ctx) = event.into_parts(); // check what action was requested - match serde_json::from_value::(event)?.event_type { + match event.event_type { EventType::SimpleError => { // generate a simple text message error using `simple_error` crate return Err(Box::new(simple_error::SimpleError::new("A simple error as requested!"))); @@ -94,7 +94,7 @@ pub(crate) async fn func(event: LambdaEvent) -> Result { msg: "OK".into(), }; - return Ok(json!(resp)); + return Ok(resp); } } } diff --git a/lambda-events/Cargo.toml b/lambda-events/Cargo.toml index 6d496b43..d9774104 100644 --- a/lambda-events/Cargo.toml +++ b/lambda-events/Cargo.toml @@ -18,11 +18,7 @@ edition = "2021" [dependencies] base64 = { workspace = true } bytes = { workspace = true, features = ["serde"], optional = true } -chrono = { workspace = true, default-features = false, features = [ - "clock", - "serde", - "std", -], optional = true } +chrono = { workspace = true, optional = true } flate2 = { version = "1.0.24", optional = true } http = { workspace = true, optional = true } http-body = { workspace = true, optional = true } diff --git a/lambda-http/src/lib.rs b/lambda-http/src/lib.rs index 265f5ef0..90e59867 100644 --- a/lambda-http/src/lib.rs +++ b/lambda-http/src/lib.rs @@ -68,6 +68,7 @@ pub use http::{self, Response}; /// Utilities to initialize and use `tracing` and `tracing-subscriber` in Lambda Functions. #[cfg(feature = "tracing")] pub use lambda_runtime::tracing; +use lambda_runtime::Diagnostic; pub use lambda_runtime::{self, service_fn, tower, Context, Error, LambdaEvent, Service}; use request::RequestFuture; use response::ResponseFuture; @@ -193,7 +194,7 @@ where S: Service, S::Future: Send + 'a, R: IntoResponse, - E: std::fmt::Debug + std::fmt::Display, + E: std::fmt::Debug + for<'b> Into>, { lambda_runtime::run(Adapter::from(handler)).await } diff --git a/lambda-http/src/streaming.rs b/lambda-http/src/streaming.rs index 217c4564..df569129 100644 --- a/lambda-http/src/streaming.rs +++ b/lambda-http/src/streaming.rs @@ -5,8 +5,9 @@ use crate::{request::LambdaRequest, RequestExt}; use bytes::Bytes; pub use http::{self, Response}; use http_body::Body; +use lambda_runtime::Diagnostic; pub use lambda_runtime::{self, tower::ServiceExt, Error, LambdaEvent, MetadataPrelude, Service, StreamResponse}; -use std::fmt::{Debug, Display}; +use std::fmt::Debug; use std::pin::Pin; use std::task::{Context, Poll}; use tokio_stream::Stream; @@ -20,7 +21,7 @@ pub async fn run_with_streaming_response<'a, S, B, E>(handler: S) -> Result<(), where S: Service, Error = E>, S::Future: Send + 'a, - E: Debug + Display, + E: Debug + for<'b> Into>, B: Body + Unpin + Send + 'static, B::Data: Into + Send, B::Error: Into + Send + Debug, diff --git a/lambda-runtime/src/diagnostic.rs b/lambda-runtime/src/diagnostic.rs new file mode 100644 index 00000000..bc9ba623 --- /dev/null +++ b/lambda-runtime/src/diagnostic.rs @@ -0,0 +1,141 @@ +use serde::{Deserialize, Serialize}; +use std::{any::type_name, borrow::Cow}; + +use crate::{deserializer::DeserializeError, Error}; + +/// Diagnostic information about an error. +/// +/// `Diagnostic` is automatically derived for some common types, +/// like boxed types that implement [`Error`][std::error::Error]. +/// +/// [`error_type`][`Diagnostic::error_type`] is derived from the type name of +/// the original error with [`std::any::type_name`] as a fallback, which may +/// not be reliable for conditional error handling. +/// You can define your own error container that implements `Into` +/// if you need to handle errors based on error types. +/// +/// Example: +/// ``` +/// use lambda_runtime::{Diagnostic, Error, LambdaEvent}; +/// use std::borrow::Cow; +/// +/// #[derive(Debug)] +/// struct ErrorResponse(Error); +/// +/// impl<'a> Into> for ErrorResponse { +/// fn into(self) -> Diagnostic<'a> { +/// Diagnostic { +/// error_type: "MyError".into(), +/// error_message: self.0.to_string().into(), +/// } +/// } +/// } +/// +/// async fn function_handler(_event: LambdaEvent<()>) -> Result<(), ErrorResponse> { +/// // ... do something +/// Ok(()) +/// } +/// ``` +#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Diagnostic<'a> { + /// Error type. + /// + /// `error_type` is derived from the type name of the original error with + /// [`std::any::type_name`] as a fallback. + /// Please implement your own `Into` if you need more reliable + /// error types. + pub error_type: Cow<'a, str>, + /// Error message. + /// + /// `error_message` is the output from the [`Display`][std::fmt::Display] + /// implementation of the original error as a fallback. + pub error_message: Cow<'a, str>, +} + +impl<'a> From for Diagnostic<'a> { + fn from(value: DeserializeError) -> Self { + Diagnostic { + error_type: type_name::().into(), + error_message: value.to_string().into(), + } + } +} + +impl<'a> From for Diagnostic<'a> { + fn from(value: Error) -> Self { + Diagnostic { + error_type: type_name::().into(), + error_message: value.to_string().into(), + } + } +} + +impl<'a, T> From> for Diagnostic<'a> +where + T: std::error::Error, +{ + fn from(value: Box) -> Self { + Diagnostic { + error_type: type_name::().into(), + error_message: value.to_string().into(), + } + } +} + +impl<'a> From> for Diagnostic<'a> { + fn from(value: Box) -> Self { + Diagnostic { + error_type: type_name::>().into(), + error_message: value.to_string().into(), + } + } +} + +impl<'a> From for Diagnostic<'a> { + fn from(value: std::convert::Infallible) -> Self { + Diagnostic { + error_type: type_name::().into(), + error_message: value.to_string().into(), + } + } +} + +impl<'a> From for Diagnostic<'a> { + fn from(value: String) -> Self { + Diagnostic { + error_type: type_name::().into(), + error_message: value.into(), + } + } +} + +impl<'a> From<&'static str> for Diagnostic<'a> { + fn from(value: &'static str) -> Self { + Diagnostic { + error_type: type_name::<&'static str>().into(), + error_message: value.into(), + } + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn round_trip_lambda_error() { + use serde_json::{json, Value}; + let expected = json!({ + "errorType": "InvalidEventDataError", + "errorMessage": "Error parsing event data.", + }); + + let actual = Diagnostic { + error_type: "InvalidEventDataError".into(), + error_message: "Error parsing event data.".into(), + }; + let actual: Value = serde_json::to_value(actual).expect("failed to serialize diagnostic"); + assert_eq!(expected, actual); + } +} diff --git a/lambda-runtime/src/layers/api_response.rs b/lambda-runtime/src/layers/api_response.rs index 266402cf..55942d0b 100644 --- a/lambda-runtime/src/layers/api_response.rs +++ b/lambda-runtime/src/layers/api_response.rs @@ -1,8 +1,9 @@ -use crate::requests::{EventCompletionRequest, IntoRequest}; -use crate::runtime::LambdaInvocation; -use crate::types::Diagnostic; -use crate::{deserializer, IntoFunctionResponse}; -use crate::{EventErrorRequest, LambdaEvent}; +use crate::{ + deserializer, + requests::{EventCompletionRequest, IntoRequest}, + runtime::LambdaInvocation, + Diagnostic, EventErrorRequest, IntoFunctionResponse, LambdaEvent, +}; use futures::ready; use futures::Stream; use lambda_runtime_api_client::{body::Body, BoxError}; diff --git a/lambda-runtime/src/lib.rs b/lambda-runtime/src/lib.rs index 9638df64..c3e68cc4 100644 --- a/lambda-runtime/src/lib.rs +++ b/lambda-runtime/src/lib.rs @@ -18,8 +18,12 @@ use tokio_stream::Stream; use tower::util::ServiceFn; pub use tower::{self, service_fn, Service}; +/// Diagnostic utilities to convert Rust types into Lambda Error types. +pub mod diagnostic; +pub use diagnostic::Diagnostic; + mod deserializer; -/// Tower middleware to be applied to runtime invocatinos. +/// Tower middleware to be applied to runtime invocations. pub mod layers; mod requests; mod runtime; @@ -35,9 +39,7 @@ mod types; use requests::EventErrorRequest; pub use runtime::{LambdaInvocation, Runtime}; -pub use types::{ - Context, Diagnostic, FunctionResponse, IntoFunctionResponse, LambdaEvent, MetadataPrelude, StreamResponse, -}; +pub use types::{Context, FunctionResponse, IntoFunctionResponse, LambdaEvent, MetadataPrelude, StreamResponse}; /// Error type that lambdas may result in pub type Error = lambda_runtime_api_client::BoxError; diff --git a/lambda-runtime/src/requests.rs b/lambda-runtime/src/requests.rs index ec893710..de0835c7 100644 --- a/lambda-runtime/src/requests.rs +++ b/lambda-runtime/src/requests.rs @@ -1,5 +1,4 @@ -use crate::types::ToStreamErrorTrailer; -use crate::{types::Diagnostic, Error, FunctionResponse, IntoFunctionResponse}; +use crate::{types::ToStreamErrorTrailer, Diagnostic, Error, FunctionResponse, IntoFunctionResponse}; use bytes::Bytes; use http::header::CONTENT_TYPE; use http::{Method, Request, Uri}; diff --git a/lambda-runtime/src/runtime.rs b/lambda-runtime/src/runtime.rs index 5ded610c..92ba5f47 100644 --- a/lambda-runtime/src/runtime.rs +++ b/lambda-runtime/src/runtime.rs @@ -1,7 +1,7 @@ -use super::requests::{IntoRequest, NextEventRequest}; -use super::types::{invoke_request_id, Diagnostic, IntoFunctionResponse, LambdaEvent}; use crate::layers::{CatchPanicService, RuntimeApiClientService, RuntimeApiResponseService}; -use crate::{Config, Context}; +use crate::requests::{IntoRequest, NextEventRequest}; +use crate::types::{invoke_request_id, IntoFunctionResponse, LambdaEvent}; +use crate::{Config, Context, Diagnostic}; use http_body_util::BodyExt; use lambda_runtime_api_client::BoxError; use lambda_runtime_api_client::Client as ApiClient; @@ -252,8 +252,7 @@ mod endpoint_tests { use super::{incoming, wrap_handler}; use crate::{ requests::{EventCompletionRequest, EventErrorRequest, IntoRequest, NextEventRequest}, - types::Diagnostic, - Config, Error, Runtime, + Config, Diagnostic, Error, Runtime, }; use futures::future::BoxFuture; use http::{HeaderValue, StatusCode}; diff --git a/lambda-runtime/src/types.rs b/lambda-runtime/src/types.rs index b4f10f71..ee09978f 100644 --- a/lambda-runtime/src/types.rs +++ b/lambda-runtime/src/types.rs @@ -5,75 +5,12 @@ use http::{header::ToStrError, HeaderMap, HeaderValue, StatusCode}; use lambda_runtime_api_client::body::Body; use serde::{Deserialize, Serialize}; use std::{ - borrow::Cow, collections::HashMap, - fmt::{Debug, Display}, + fmt::Debug, time::{Duration, SystemTime}, }; use tokio_stream::Stream; -/// Diagnostic information about an error. -/// -/// `Diagnostic` is automatically derived for types that implement -/// [`Display`][std::fmt::Display]; e.g., [`Error`][std::error::Error]. -/// -/// [`error_type`][`Diagnostic::error_type`] is derived from the type name of -/// the original error with [`std::any::type_name`] as a fallback, which may -/// not be reliable for conditional error handling. -/// You can define your own error container that implements `Into` -/// if you need to handle errors based on error types. -/// -/// Example: -/// ``` -/// use lambda_runtime::{Diagnostic, Error, LambdaEvent}; -/// use std::borrow::Cow; -/// -/// #[derive(Debug)] -/// struct ErrorResponse(Error); -/// -/// impl<'a> Into> for ErrorResponse { -/// fn into(self) -> Diagnostic<'a> { -/// Diagnostic { -/// error_type: Cow::Borrowed("MyError"), -/// error_message: Cow::Owned(self.0.to_string()), -/// } -/// } -/// } -/// -/// async fn function_handler(_event: LambdaEvent<()>) -> Result<(), ErrorResponse> { -/// // ... do something -/// Ok(()) -/// } -/// ``` -#[derive(Debug, Eq, PartialEq, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Diagnostic<'a> { - /// Error type. - /// - /// `error_type` is derived from the type name of the original error with - /// [`std::any::type_name`] as a fallback. - /// Please implement your own `Into` if you need more reliable - /// error types. - pub error_type: Cow<'a, str>, - /// Error message. - /// - /// `error_message` is the output from the [`Display`][std::fmt::Display] - /// implementation of the original error as a fallback. - pub error_message: Cow<'a, str>, -} - -impl<'a, T> From for Diagnostic<'a> -where - T: Display, -{ - fn from(value: T) -> Self { - Diagnostic { - error_type: Cow::Borrowed(std::any::type_name::()), - error_message: Cow::Owned(format!("{value}")), - } - } -} - /// Client context sent by the AWS Mobile SDK. #[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq)] pub struct ClientContext { @@ -342,22 +279,6 @@ mod test { use crate::Config; use std::sync::Arc; - #[test] - fn round_trip_lambda_error() { - use serde_json::{json, Value}; - let expected = json!({ - "errorType": "InvalidEventDataError", - "errorMessage": "Error parsing event data.", - }); - - let actual = Diagnostic { - error_type: Cow::Borrowed("InvalidEventDataError"), - error_message: Cow::Borrowed("Error parsing event data."), - }; - let actual: Value = serde_json::to_value(actual).expect("failed to serialize diagnostic"); - assert_eq!(expected, actual); - } - #[test] fn context_with_expected_values_and_types_resolves() { let config = Arc::new(Config::default());