-
Notifications
You must be signed in to change notification settings - Fork 182
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
Conversation
Hey! That's a very nice approach and one of the options we were debating as an internals refactor. Will review soon. |
I'm for it (as previously discussed when you were working on the errors @Jarema 😁 ) |
I've rebased the branch over the fresh main branch and also fixed formatting along with a typo in the error description. |
There was a problem hiding this 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!
async-nats/src/nats_error.rs
Outdated
// 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> |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 ?
There was a problem hiding this comment.
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 :)
- Instead of defining
::new
and::with_source(kind: ..., source: ...)
it's better to defineimpl<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)
- 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)
- And finally the aforementioned switch to concrete errors everywhere in the package
wdyt?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 ))
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.
I have added 3 big changes into the PR
FooErrorKind::SomeError.into() // instead of FooError::new(FooErrorKind::SomeError)
source.with_kind(FooErrorKind::SomeError) // instead of FooError::with_source(FooErrorKind::SomeError, source) |
That was done by purpose. Users (and library itself) need a clean way to get to the underlying JetStream Error easily. 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 |
@Jarema thank you for the explanation. Well, I need to think a bit more about it. Maybe it would block the generalization of the 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 |
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.
I've dropped the commit and returned to the |
It's the same in Rust - you usually do not want to expose the types of dependencies of your library. For learning context: There are exceptions though. One of few non-library types you can see exposed in the wild a lot is |
@nepalez Looks good now! Rename the file and error and it's LGTM :). |
@Jarema done )) |
There was a problem hiding this 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 { |
There was a problem hiding this comment.
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.
pub fn kind(&self) -> Kind { | |
pub fn kind(&self) -> &Kind { |
There was a problem hiding this comment.
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 ))
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM!
There was a problem hiding this 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,
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 :).
I believe we don't because this context is still available in the error's The only thing I am not sure about is this. For now all kinds require the I could loose this requirement and let the developer to decide if he is going to imlement the |
Ah, yes, you're right! The context is available, though printed as
so all Let's get it merged and discuss potential further improvements outside of this PR. |
Yeah, my bad. Fixed (removed excessive |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM!
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:
The new definition:
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).