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

[Refactoring] Use a generic error type instead of the macro error_impls #1047

Merged
merged 6 commits into from
Jul 29, 2023

Conversation

nepalez
Copy link
Contributor

@nepalez nepalez commented Jul 21, 2023

While trying to recognize how the gem works I found this TODO and decided to implement it (well, this is how I learn things).

But at the moment the implementation got ready, I asked myself, why the same functionality cannot be expressed better with generic structs, and found no answer. That's why instead of implementing another macro (derive or attribute), I restarted my refactoring from scratch and removed the very necessity of the macro in favor of NatsError<T>.

This type represents any error to be raised by the crate. It is generic over the enum representing the kind of the error.

Unlike the previous code where binding between the kind and the error used to be provided by the macro error_impls, this definition makes the corresponding error bound to the kind more idiomatically.

The previous syntax:

#[derive(Clone, Debug, PartialEq)]
pub enum FooErrorKind {
  Bar,
  Baz,
}

#[derive(Debug)]
pub struct FooError {
  kind: FooErrorKind,
  source: Box<dyn Error + Send + Sync + 'static>,
}

error_impls!("FooError", "FooErrorKind");

The new definition:

#[derive(Clone, Debug, PartialEq)]
pub enum FooErrorKind {
  Bar,
  Baz,
}

pub type FooError = NatsError<FooErrorKind>;

In addition to being more expressive (which is, I agree, too opinionated), the new syntax lets IDE-s to understand the code better (most of them cannot analyze the code inside the macro).

@nepalez nepalez marked this pull request as ready for review July 21, 2023 15:15
@Jarema
Copy link
Member

Jarema commented Jul 22, 2023

Hey!

That's a very nice approach and one of the options we were debating as an internals refactor.

Will review soon.
Thank you!

@caspervonb caspervonb requested review from caspervonb and Jarema July 22, 2023 10:21
@caspervonb
Copy link
Collaborator

I'm for it (as previously discussed when you were working on the errors @Jarema 😁 )

@nepalez
Copy link
Contributor Author

nepalez commented Jul 24, 2023

I've rebased the branch over the fresh main branch and also fixed formatting along with a typo in the error description.

Copy link
Member

@Jarema Jarema left a comment

Choose a reason for hiding this comment

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

In general, this is a really nice change, but there are a few nits to be more Rust-idiomatic and consistent with the rest of library.

Thanks a lot for the work you've done here!

// Notice: see unit tests to see how to use this generic error class.

/// The error type for the NATS client, generic by the kind of error.
pub struct NatsError<Kind>
Copy link
Member

Choose a reason for hiding this comment

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

It's echoey (async_nats::nats_error::NatsError) and not very Rust-idomatic.

Make it Error. Same for the file name.

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'm not sure about this. The reason is nats already have the Error type which is a part of the public interface.

Maybe it is better to use something like CustomError<T> here to prevent echoing?

Copy link
Contributor Author

@nepalez nepalez Jul 27, 2023

Choose a reason for hiding this comment

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

Alternatively, I could customize all errors like this as a types of NatsError, so that the crate would only provide the NatsError<T>, and then make 2 renamings:

  • crate::Error -> crate::SourceError (dynamic)
  • crate::NatsError -> crate::Error (static)

Copy link
Member

Choose a reason for hiding this comment

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

The crate::Error was added just for having simple alias for typical Rust Boxed error with intention to be used like in all doc examples. It became irrelevant after introducing concrete errors for whole API.

Then it was used for source, to have less verbose and consistent syntax at each source field. This became irrelevant too, as your improvement gets rid of repeated structs with source field.

Changing crate::Error name would be a breaking change without good reason. I would prefer to deprecate it.

Also, this new NatsError does not have to be public, as we make all aliases (type Foo<FooKind> public).
I was considering if it would "voldemort" the type, but it should be fine.

Copy link
Member

Choose a reason for hiding this comment

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

Oh, and yes, we need to add concrete errors to service API. We want the whole API of this crate to be based of concrete errors.

Copy link
Collaborator

@caspervonb caspervonb Jul 27, 2023

Choose a reason for hiding this comment

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

Tangentially, crate::Error should in the long term, be removed when nothing returns it anymore as explicitly saying what it is in say main makes for better documentation examples.

This type error type introduced here being public depends on how rustdoc renders it if it was voldemorted.

Copy link
Member

Choose a reason for hiding this comment

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

actually, it has to be public, otherwise we would get:

error[E0446]: crate-private type `nats_error::NatsError<jetstream::stream::ConsumerErrorKind>` in public interface
    --> async-nats/src/jetstream/stream.rs:1782:1
     |
1782 | pub type ConsumerError = NatsError<ConsumerErrorKind>;
     | ^^^^^^^^^^^^^^^^^^^^^^ can't leak crate-private type
     |
    ::: async-nats/src/nats_error.rs:6:1
     |
6    | pub(crate) struct NatsError<Kind>
     | --------------------------------- `nats_error::NatsError<jetstream::stream::ConsumerErrorKind>` declared as crate-private

But it does not have to be re-exported as crate::Error, but can remain crate::error::Error, while deprecating crate::Error.

WDYT @caspervonb ?

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 I need to work on this PR a bit more :)

  1. Instead of defining ::new and ::with_source(kind: ..., source: ...) it's better to define impl<Kind> From<Kind> for NatsError<Kind>, to exclude repetition of the error kind:

Instead of

FooError::with_source(FooErrorKind::Baz, source)

we could get

FooErrorKind::Baz.into().with_source(source)
  1. Then another improvement. Here and there there is a code like this, where some additional message is added to the source error. Wouldn't it be better if those kinds would be specialized instead and move these error messages into the implementation of the Display trait?

Then instead of

FooErrorKind::SharedProblem.into().with_source(format!("some custom problem: {}", err))

we would get

FooErrorKind::CustomProblem.into().with_source(err)
  1. And finally the aforementioned switch to concrete errors everywhere in the package

wdyt?

Copy link
Member

Choose a reason for hiding this comment

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

To be fair, I would not overthink it.
Keep in mind it's an internal API, and often simple and explicit one is better than a smart one. Especially if you want to encourage contributions. The lower the library learning curve, the better :).

Regarding kinds - this was deliberate decision:
We expose error enum variants only if it makes sense for users to match against them, which is when user can take some action because of error kind returned.

In case of those errors you linked as example it does not make sense for the source kind to exist as separate kind, nor as a kind on returning error level. It's just giving contextual information when logging, which is really easy with those literal &str's.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Ahh, I missed your comment yesterday. Anyway, I don't use those new methods into() and with_kind() so they can be dropped without much sadness ))

nepalez added 3 commits July 28, 2023 15:00
This type represents any error to be raised by the crate.
It is generic over the enum representing the kind of the error.

Unlike the previous code where binding between the kind and the error
used to be provided by the macro `error_impls`, this definition
makes the corresponding error bound to the kind more idiomatically
through the generic type parameter.

The previous syntax:

   #[derive(Clone, Debug, PartialEq)]
   pub enum FooErrorKind {
      Bar,
      Baz,
   }

   #[derive(Debug)]
   pub struct FooError {
      kind: FooErrorKind,
      source: Box<dyn Error + Send + Sync + 'static>,
   }

   error_impls!("FooError", "FooErrorKind");

The new definition:

   #[derive(Clone, Debug, PartialEq)]
   pub enum FooErrorKind {
      Bar,
      Baz,
   }

   pub type FooError = NatsError<FooErrorKind>;

In addition to being more idiomatic, the new syntax lets IDE-s
to understand the code better (most of them cannot analyze
the code inside the macro).
This commit applies the parameterized NatsError
to all errors previously used the `error_impls` macro.
After switching to the `NatsError<T>` this macro
is not necessary any more.
@nepalez
Copy link
Contributor Author

nepalez commented Jul 28, 2023

I have added 3 big changes into the PR

  • "normalized" errors so that the source is separated from the kind (previously JetStream(err) version of some kinds were keeping the source inside)

  • added the default implementation of Display right to the error, and in the code moved impl Display to the kinds (this is becasue after the previous step this became a common pattern)

  • (probably this should be rolled back to follow our previous discussion) implemented 2 constructors available out of the box to reflect the fact an error is defined by its kind:

FooErrorKind::SomeError.into() // instead of FooError::new(FooErrorKind::SomeError)
source.with_kind(FooErrorKind::SomeError) // instead of FooError::with_source(FooErrorKind::SomeError, source)

@Jarema
Copy link
Member

Jarema commented Jul 28, 2023

I have added 3 big changes into the PR

  • "normalized" errors so that the source is separated from the kind (previously JetStream(err) version of some kinds were keeping the source inside)

That was done by purpose.

Users (and library itself) need a clean way to get to the underlying JetStream Error easily.
It does not look nice if user matches against kind, sees that it is JetStream Kind, yet they need to unwrap if source is Some and downcast error to get the JetStream Error. And the JetStream error is what in many cases user or we need to match against. It also adds possibility to introduce bugs by forgetting to add that source.

I know it's not perfect, but to be fair, JetStream errors on server side are not very clean, and new ones are added over time, so adding them directly to Kind would require all kinds to be non_exhaustive plus it's really hard to find all possible JS errors that can happen for given API call (to not call it impossible).

@nepalez
Copy link
Contributor Author

nepalez commented Jul 28, 2023

@Jarema thank you for the explanation. Well, I need to think a bit more about it. Maybe it would block the generalization of the Display.

Another question is about your previous comment. As far, as I understood, you're not a big fan of making all errors wrapped and standardized in this way? To be honest, I'm not experienced enough for rust codebases, so I have no any opinion about what approach is preferable in Rust.

In Ruby world where I'm from, I always prefer the library to provide its own errors, even though it is just a thin wrapper around some third-party ones. This simplifies the debugging by pointing out to the specific package. Following this approach, I could potentially wrap all std::io::Error-s into the corresponding crate::Error, but should I?

From now and on it is enough to implement the Display
trait for the kind of the error. Then the Display trait
will be automatically implemented for the corresponding error.

In case the error contains the source, its formatting
will include both the kind and the source, otherwise
only the kind will be used for the display.

The method `formatted_source` is dropped.
@nepalez
Copy link
Contributor Author

nepalez commented Jul 28, 2023

I've dropped the commit and returned to the JetStream(err). The implementation of the Display trait survived :)

@Jarema
Copy link
Member

Jarema commented Jul 28, 2023

In Ruby world where I'm from, I always prefer the library to provide its own errors, even though it is just a thin wrapper around some third-party ones. This simplifies the debugging by pointing out to the specific package. Following this approach, I could potentially wrap all std::io::Error-s into the corresponding crate::Error, but should I?

It's the same in Rust - you usually do not want to expose the types of dependencies of your library.
However, JetStreamError IS a type from this crate :).

For learning context: There are exceptions though. One of few non-library types you can see exposed in the wild a lot is std::io::Error.

@Jarema
Copy link
Member

Jarema commented Jul 28, 2023

@nepalez Looks good now! Rename the file and error and it's LGTM :).

@nepalez
Copy link
Contributor Author

nepalez commented Jul 29, 2023

@Jarema done ))

Copy link
Collaborator

@caspervonb caspervonb left a comment

Choose a reason for hiding this comment

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

Do we want this to be a ref instead of cloning? cc @Jarema

}

// In some cases the kind doesn't implement `Copy` trait
pub fn kind(&self) -> Kind {
Copy link
Collaborator

Choose a reason for hiding this comment

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

This can just return ref? no clone needed.

Suggested change
pub fn kind(&self) -> Kind {
pub fn kind(&self) -> &Kind {

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 asked myself the same question, but left this method for the backward compatibitlity only. Without it we could just make the kind attribute public ))

Copy link
Member

Choose a reason for hiding this comment

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

Its Copy most of the time. Clone only in case or JetStream error. And if you look at std::io::Error its also returning copy.

Copy link
Contributor Author

@nepalez nepalez Jul 29, 2023

Choose a reason for hiding this comment

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

Not only the JetStream, but also an ErrorResponse keeping a string inside, which is not copy.

Copy link
Member

@Jarema Jarema left a comment

Choose a reason for hiding this comment

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

LGTM!

Copy link
Member

@Jarema Jarema left a comment

Choose a reason for hiding this comment

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

Oh, that LGTM was a little premature.

Not sure for reason on implementing Display on Kinds, instead of Error?
That way, we loose quite a lot of useful context,

like here:
https://github.com/nats-io/nats.rs/pull/1047/files#diff-09d2b3ba6ced37b50fa193faa24082492564d93fffcf3068af4ee69a4be7e54dL884-R862

or here:
https://github.com/nats-io/nats.rs/pull/1047/files#diff-014b24d4d8bf5b62ffeed0269754925ee12069d078d371058be69986cb926ea8L963-L972

Plus, the default display rather hurts than helps - without it, if you add new error compiler will remind you that you need a nice display impl for it. Without it - it's very easy to skip it and have the very basic one, which is too basic in my opinion.

Sorry for all those change requests.
That's why we prefer iterative work with smaller PRs. Much easier to merge in changes that way :).

@nepalez
Copy link
Contributor Author

nepalez commented Jul 29, 2023

we loose quite a lot of useful context,

I believe we don't because this context is still available in the error's Display. As long as the error has a source, its description is added to the error.

The only thing I am not sure about is this. For now all kinds require the Display to be implemented for them.

I could loose this requirement and let the developer to decide if he is going to imlement the Display for the kind or for the whole error only. Only if he implements the Display for the kind, the error would be displayed as well out of the box, otherwise it should be implemented for the specific error to satisfy its std::error::Error trait.

@nepalez nepalez requested a review from Jarema July 29, 2023 16:37
@Jarema
Copy link
Member

Jarema commented Jul 29, 2023

we loose quite a lot of useful context,

I believe we don't because this context is still available in the error's Display. As long as the error has a source, its description is added to the error.

The only thing I am not sure about is this. For now all kinds require the Display to be implemented for them.

I could loose this requirement and let the developer to decide if he is going to imlement the Display for the kind or for the whole error only. Only if he implements the Display for the kind, the error would be displayed as well out of the box, otherwise it should be implemented for the specific error to satisfy its std::error::Error trait.

Ah, yes, you're right! The context is available, though printed as

failed publishing object chunks:: failed chunk publish: something

so all : at the end at the errors should go away. But rather in in-code impls, not in generic error impl.

Let's get it merged and discuss potential further improvements outside of this PR.

@nepalez
Copy link
Contributor Author

nepalez commented Jul 29, 2023

Yeah, my bad. Fixed (removed excessive :)

Copy link
Member

@Jarema Jarema left a comment

Choose a reason for hiding this comment

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

LGTM!

@Jarema Jarema merged commit f20934d into nats-io:main Jul 29, 2023
@Jarema Jarema mentioned this pull request Sep 21, 2023
@nepalez nepalez deleted the nats_error branch September 22, 2023 12:46
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.

None yet

3 participants