Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow error response customization #828

Merged
merged 2 commits into from
Feb 21, 2024
Merged

Conversation

kikuomax
Copy link
Contributor

Issue #, if available:

Description of changes:

  • F::Error of Runtime::run and run is required to implement Into<Diagnostic<'a>> instead of std::fmt::Display
  • Into<Diagnostic<'a>> is implemented for any types that implement std::fmt::Display as a fallback and for backward compatibility
  • Diagnostic and its fields become public so that users can customize error responses
  • error_type and error_message of Diagnostic are wrapped in std::borrow::Cow so that they can hold both borrowed and owned strings. An example of the situation where an owned string is necessary is in the fallback implementation of From<Display> for Diagnostic, which generates error_message inside from method and it has to outlive the method call.

By submitting this pull request

  • I confirm that my contribution is made under the terms of the Apache 2.0 license.
  • I confirm that I've made a best effort attempt to update all relevant documentation.

Error responses of `lambda_runtime` are now customizable. One use case
is to avoid using `std::any::type_name` for error types, which is not
reliable for determining subsequent program behavior.

`F::Error` of `Runtime::run` and `run` is required to implement
`Into<Diagnostic<'a>>` instead of `Display`.

`EventErrorRequest` accepts a value implementing `Into<Diagnostic<'a>>`
instead of the error type and message.

`Diagnostic` becomes public because users may implement their own
conversions. Its fields are wrapped in `Cow` so that they can carry both
borrowed and owned strings. `Diagnostic` is implemented for every type
that implements `Display` as a fallback, which also ensures backward
compatibility.
The comments on `Diagnositc` shows how to customize error responses with
an example.
Copy link
Contributor

@calavera calavera left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really nice, thank you!

@calavera calavera merged commit 387b07c into awslabs:main Feb 21, 2024
4 checks passed
@kikuomax kikuomax deleted the custom-error-type branch February 22, 2024 01:38
@@ -14,8 +14,9 @@ use hyper::{body::Incoming, http::Request};
use lambda_runtime_api_client::{body::Body, BoxError, Client};
use serde::{Deserialize, Serialize};
use std::{
borrow::Cow,
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why use Cow? Why not just use &str?

Copy link
Contributor Author

@kikuomax kikuomax Mar 3, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@evbo I introduced Cow to overcome a lifetime issue rather than performance concern. Since the fallback conversion from Error (Display) into Diagnostic had to dynamically format error_message, I was not able to simply return &str:

impl<'a, T> From<T> for Diagnostic<'a>
where
T: Display,
{
fn from(value: T) -> Self {
Diagnostic {
error_type: Cow::Borrowed(std::any::type_name::<T>()),
error_message: Cow::Owned(format!("{value}")),
}
}
}

However, regarding error_type, it could have been &str. I did not notice that until you pointed it out.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

error_type can't always be &str in the case where you're borrowing the variant name (such as with strum::AsRefStr), which is helpful for auto-converting Variants to error types.

I'm not sure why both can't just be String? Just seems like a lot of extra typing using Cow, sorry to nitpick

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think some might say the opposite "why String? they are &'static str most of the time."

Btw, you may avoid explicit use of Cow as long as you are dealing with &str or String:

let msg = "message".to_string();
Diagnostic {
    error_type: "SomeError".into(), // &str → Cow::Borrowed
    error_message: msg.into(), // String → Cow::Owned
};

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the into - that's much cleaner. I hate to burn up so much time, but why would someone say: "why String"? If we're creating millions of exceptions, then I understand the cost of heap allocation would eventually cost noticeably, but maybe at most 1 exception gets generated once in a blue moon, so isn't it fair using String?

@kikuomax
Copy link
Contributor Author

kikuomax commented Mar 4, 2024

@evbo Have you tried something like the following?

#[derive(thiserror::Error, Debug)]
pub enum MyError {
    #[error("some error: {0}")]
    SomeError(String),
    // ... other variants
}

impl<'a> Into<Diagnostic<'a>> for MyError {
    fn into(self) -> Diagnostic<'a> {
        match self {
            Self::SomeError(s) => Diagnostic {
                error_type: Cow::Borrowed("SomeError"),
                error_message: Cow::Owned(s),
            },
            // ... other variants
        }
    }
}

The above will cause conflict between From<Display> for Diagnostic and Into<Diagnostic> for MyError, because MyError implements derived Display. So you cannot directly implement Diagnostic for MyError; more generally, any type implementing std::error::Error.

A workaround in this case may be to define a wrapper struct for your error type and implement Diagnostic for the wrapper like in the example of Diagnostic:

struct ErrorResponse(MyError);

impl<'a> Into<Diagnostic<'a>> for ErrorResponse {
    fn into(self) -> Diagnostic<'a> {
        match self.0 {
            ErrorResponse::SomeError(s) => Diagnostic {
                error_type: Cow::Borrowed("SomeError"),
                error_message: Cow::Owned(s),
            },
            // ... other variants
        }
    }
}

Display must not be implemented for ErrorResponse to avoid the same conflict.

Obviously, I should refine the documentation.

@evbo
Copy link

evbo commented Mar 5, 2024

@kikuomax I take back my earlier messages. I think you're right - this can be solved with better docs.

I think the best ergonomics are achieved when function_handler returns the error and then you map_err to the top-level struct by intercepting what gets returned to lambda_runtime::run as shown here:
https://docs.aws.amazon.com/lambda/latest/dg/rust-handler.html#rust-shared-state

@kikuomax
Copy link
Contributor Author

kikuomax commented Mar 9, 2024

@evbo I am planning a PR to improve the documentation. How about having the following in the API documentation? Does it make better sense?

Diagnostic is automatically generated from any types that implement Display; e.g., Error. The default behavior puts the type name of the original error obtained with std::any::type_name into error_type, which may not be reliable for conditional error handling. You can define your own error container that implements Into<Diagnostic> if you need more accurate error types.

Example:

use lambda_runtime::{service_fn, Diagnostic, Error, LambdaEvent};

#[derive(Debug)]
struct ErrorResponse(Error);

impl<'a> Into<Diagnostic<'a>> 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<(), Error> {
   // ... do something
   Ok(())
}

#[tokio::main]
async fn main() -> Result<(), Error> {
    lambda_runtime::run(service_fn(|event| async {
        function_handler(event).await.map_err(ErrorResponse)
    })).await
}

The container ErrorResponse in the above snippet is necessary because you cannot directly implement Into<Diagnostic> for types that implement Error. For instance, the following code will not compile:

ⓘ compile_fail

use lambda_runtime::Diagnostic;

#[derive(Debug)]
struct MyError(String);

impl std::fmt::Display for MyError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "MyError: {}", self.0)
    }
}

impl std::error::Error for MyError {}

// ERROR! conflicting implementations of trait `Into<Diagnostic<'_>>` for type `MyError`
impl<'a> Into<Diagnostic<'a>> for MyError {
    fn into(self) -> Diagnostic<'a> {
        Diagnostic {
            error_type: "MyError".into(),
            error_message: self.0.into(),
        }
    }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants