Skip to content

Move from error-chain to thiserror #394

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

Closed
wants to merge 13 commits into from
Closed

Move from error-chain to thiserror #394

wants to merge 13 commits into from

Conversation

pwoolcoc
Copy link
Contributor

@pwoolcoc pwoolcoc commented Mar 23, 2020

This is a first attempt at removing error-chain and using thiserror instead.

A couple notes:

  • error-chain sets up an Error/ErrorKind pattern, while thiserror seems to work better with a single Error enum. Some of the API choices I made in this PR are a result of this difference, I tried to keep the call sites similar as much as possible.
  • When rebasing, the Cargo.lock had quite a few conflicts. Instead of risking trying to resolve them manually, I deleted the lock file and re-created it. Apologies, that seems to have caused quite a large diff for that file.

Closes #344

@pwoolcoc pwoolcoc changed the title Issue 344 switch to thiserror Move from error-chain to thiserror Mar 23, 2020
Copy link
Collaborator

@ordian ordian left a comment

Choose a reason for hiding this comment

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

Thanks for the PR!

I've left some comments, but will take a closer look later this week.

src/errors.rs Outdated
#[error("{0}")]
Custom(String),

/// Wraps another error in order to provide more context
Copy link
Collaborator

Choose a reason for hiding this comment

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

hmm, is it a common way of doing this thing?
I see that https://github.com/dtolnay/anyhow for example has a context function for this. Maybe thiserror has something similar?

Copy link
Contributor Author

@pwoolcoc pwoolcoc Mar 25, 2020

Choose a reason for hiding this comment

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

Unless I'm missing something, thiserror allows you to add source for an error variant by one of three ways:

  1. prepending the field with #[from],
  2. prepending the field with #[source], or
  3. naming the field source

In all cases, the source of the error variant is kept in the variant itself, and I can't find any way to add this source information alongside the Error enum in the same way that error-chain did with the Error/ErrorKind setup. I could rename the enum to be ErrorKind and have an Error struct that stores the ErrorKind and a source, but then we are basically reimplementing error-chain by hand and we lose the benefits of using thiserror since we won't be able to take advantage of the code generation that it does to allow us to convert those source errors to our Error enum.

Copy link
Collaborator

Choose a reason for hiding this comment

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

It just feels like a something is not right here, either:

  1. thiserror is not the right tool for the job, or
  2. we should redesign the error types exposed in the library

and I'll need more time to think about this

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've got an idea about this that I'll try out today

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Thinking a little more about this, I think using a combination of thiserror and anyhow might be the right way to go. thiserror is good for defining the error types for the library and deriving the Error trait on them, and anyhow seems perfect for working with them within the application itself. I'll give that a try and see how it turns out

Copy link
Collaborator

Choose a reason for hiding this comment

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

any updates on this? (also needs merging with master)

Copy link
Collaborator

Choose a reason for hiding this comment

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

If we need to add some context to each error, maybe the error type should be

struct Error {
    pub error: ErrorKind,
    context: &'static str,
}

or something along the lines, or we could add more variants of errors to the lib.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

yea that's what I'm in the process of moving to. sorry I haven't been more responsive, I'm still working on this and will hopefully have an update for you tomorrow

Copy link
Collaborator

Choose a reason for hiding this comment

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

no worries :) take your time and thank you for the PR!

@pwoolcoc
Copy link
Contributor Author

pwoolcoc commented Apr 7, 2020

@ordian ok, I think this might be the way to go. I've kept the thiserror enum but return values all use anyhow::Result. This seems to be a good mix between library-specific errors & adding ad-hoc context to an error

Copy link
Collaborator

@ordian ordian left a comment

Choose a reason for hiding this comment

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

So here we're keeping the lib Error type, but all functions return anyhow::Result. Is a user expected to downcast the error to the lib Error? What are other possible values of the error type besides &'static str?
TBH I'm not convinced this is an improvement. I would reassess what kind of static string errors we return and maybe create enum variants for them. Also not sure how with_context messages are helpful in the lib.

Curious what @killercup thinks about this.

Comment on lines -161 to -162
if let Some(backtrace) = err.backtrace() {
eprintln!("Backtrace: {:?}", backtrace);
Copy link
Collaborator

Choose a reason for hiding this comment

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

is it not possible to keep backtraces with anyhow? :(

Copy link
Contributor Author

Choose a reason for hiding this comment

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

anyhow can display backtraces as long as it was complied by nightly, since the stdlib backtrace feature is still unstable. I've tried to convince failure::Fail::backtrace to accept an anyhow::Error and try to get a backtrace out of it, but haven't had any luck. anyhow::Error will deref to a dyn Error + Send + Sync + 'static, but Fail::backtrace complains that it is unsized.

I've added a feature that will allow a user to compile with backtrace support as long as they are on nightly, but if there is a better way I'd be more than happy to use it.

Copy link
Collaborator

Choose a reason for hiding this comment

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

@killercup are backtraces used for identifying bugs in cargo-edit if it fails to run on some input? maybe it's not that helpful for the users after all

Copy link

@yaahc yaahc Apr 8, 2020

Choose a reason for hiding this comment

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

I could have sworn that anyhow brought in a dependency on backtrace-rs on stable but apparently not. I don't mean to shamelessly plug my own stuff but if you want stable backtraces you can swap from anyhow to eyre which is a fork that adds support for custom context types, which you could define to include a backtrace::Backtrace

Copy link
Owner

Choose a reason for hiding this comment

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

Losing backtraces is kind of a shame from a technical standpoint, but I don't think they are being relied on by anyone using cargo-edit, because we have such amazing error descriptions! 😉

Kidding aside, I think this is fine. When we finally end up packaging binaries we can compile them with nightly and get some backtraces for free.

@yaahc, I just got what the name is about. Amazing. I'm also totally fine with using your fork. Or switching to it in a later PR.

@killercup
Copy link
Owner

This is quite the diff, I'll try to read it sometime today.

Also cc @yaahc for errors in a small-but-in-production code base :)

@yaahc
Copy link

yaahc commented Apr 7, 2020

I'm not really clear on which part is the library and which part isn't, assuming everything in src/bin is the bin and everything else is lib?

So here we're keeping the lib Error type, but all functions return anyhow::Result. Is a user expected to downcast the error to the lib Error?

If you're going to immediately erase the error type then there is very little value to using thiserror. For library errors if you intend for the errors to be handleable by the caller then you almost certainly shouldn't use anyhow, if you still need context errors then what you want is a higher level error type that contains the root cause error types so you can just do map_err(HigherError::RelevantVariant).

@pwoolcoc
Copy link
Contributor Author

pwoolcoc commented Apr 7, 2020

thanks @ordian @killercup @yaahc , I think I know where to go with it.

As @yaahc pointed out, if we immediately erase the error type by
converting it to `anyhow::Error` then there's not much reason to have a
library error type.

This commit puts _all_ errors generated in the library into the Error
enum and removes use of `anyhow` from the library completely.

Note that `anyhow` is still used in the 3 binaries.
@pwoolcoc
Copy link
Contributor Author

pwoolcoc commented Apr 7, 2020

r? @ordian (and @yaahc if you are available!)

src/errors.rs Outdated
Comment on lines 5 to 188

/// Failed to parse a version for a dependency
#[error("The version `{0}` for the dependency `{1}` couldn't be parsed")]
ParseReqVersion(String, String, #[source] semver::ReqParseError),

/// Failed to parse a version for a dependency
#[error("The version `{0}` for the dependency `{1}` couldn't be parsed")]
ParseVersion(String, String, #[source] semver::SemVerError),

/// An invalid crate version requirement was encountered
#[error("Invalid crate version requirement")]
InvalidCrateVersionReq(#[source] semver::ReqParseError),

/// Unable to get crate name from a URI
#[error("Unable to obtain crate informations from `{0}`.\n")]
ParseCrateNameFromUri(String),

/// Unable to get a crate from a git repository
#[error("Failed to fetch crate from git")]
FetchCrateFromGit(#[source] reqwest::Error),

/// Received an invalid response from a git repository
#[error("Git response not a valid `String`")]
InvalidGitResponse(#[source] std::io::Error),

/// Could not read manifest contents
#[error("Failed to read manifest contents")]
ManifestReadError(#[source] std::io::Error),

/// Could not parse Cargo.toml
#[error("Unable to parse Cargo.toml")]
ManifestParseError(#[source] Box<Error>),

/// Cargo.toml contained invalid TOML
#[error("Manifest not valid TOML")]
ManifestInvalidToml(#[source] toml_edit::TomlError),

/// Could not found Cargo.toml
#[error("Failed to find Cargo.toml")]
ManifestNotLocated(#[source] std::io::Error),

/// Could not get cargo metadata
#[error("Failed to get cargo file metadata")]
GetCargoMetadata(#[source] std::io::Error),

/// Could not get current directory
#[error("Failed to get current directory")]
GetCwd(#[source] std::io::Error),

/// Could not set output colour
#[error("Failed to set output colour")]
SetOutputColour(#[source] std::io::Error),

/// Could not write upgrade message
#[error("Failed to write upgrade message")]
WriteUpgradeMessage(#[source] std::io::Error),

/// Could not clear output colour
#[error("Failed to clear output colour")]
ClearOutputColour(#[source] std::io::Error),

/// Could not write upgraded versions
#[error("Failed to write upgrade versions")]
WriteUpgradeVersions(#[source] std::io::Error),

/// Could not print upgrade message
#[error("Failed to print upgrade message")]
PrintUpgradeMessage(#[source] std::io::Error),

/// Could not truncate Cargo.toml
#[error("Failed to truncate Cargo.toml")]
TruncateCargoToml(#[source] std::io::Error),

/// Could not write updated Cargo.toml
#[error("Failed to write updated Cargo.toml")]
WriteUpdatedCargoToml(#[source] std::io::Error),

/// Missing Version Field
#[error("Missing version field")]
MissingVersionField,

/// Could not write new manifest contents
#[error("Failed to write new manifest contents")]
WriteNewManifestContents(#[source] Box<Error>),

/// Could not open Cargo.toml
#[error("Unable to open local Cargo.toml")]
OpenLocalManifest(#[source] Box<Error>),

/// Git repo URL seems incomplete
#[error("Git repo url seems incomplete")]
IncompleteGitUrl,

/// Could not parse git repo URL
#[error("Unable to parse git repo URL")]
ParseGitUrl,

/// Found a virtual manifest instead of an actual manifest
#[error("Found virtual manifest, but this command requires running against an actual package in this workspace. Try adding `--workspace`.")]
VirtualManifest,
Copy link
Collaborator

Choose a reason for hiding this comment

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

oh boy, I didn't expect that many variants of errors 😅

I understand that on one hand we want to provide as much details about the error as possible, but on the other hand, users of this library won't need to differentiate between SetOutputColour and WriteUpgradeMessage. IMHO it's fine to shrink all io errors into one, all toml parse errors into one, etc.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

🤣 yea, agreed that it's a lot. I've cut it way down in my latest commit, let me know what you think

Comment on lines +19 to +24
/// A ReqParseError from the semver crate
#[error(transparent)]
SemVerParse(#[from] semver::ReqParseError),
/// A SemVerError from the semver crate
#[error(transparent)]
SemVer(#[from] semver::SemVerError),
Copy link
Collaborator

Choose a reason for hiding this comment

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

can we merge these into one?

Copy link
Contributor Author

@pwoolcoc pwoolcoc Apr 9, 2020

Choose a reason for hiding this comment

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

Unfortunately the source errors are completely separate types so we'd either lose those or have to cast them to trait objects or something

Comment on lines +71 to +73
/// Unable to get a crate from a git repository
#[error("Failed to fetch crate from git")]
FetchCrateFromGit(#[source] reqwest::Error),
Copy link
Collaborator

Choose a reason for hiding this comment

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

and this into Reqwest error

Comment on lines +55 to +61
/// Unable to find the specified resource
#[error("The resource `{0}` could not be found.")]
NotFound(String),

/// Unable to find the specified resource
#[error("The resource `{0}` could not be found in `{1}`.")]
NotFoundIn(String, String),
Copy link
Collaborator

Choose a reason for hiding this comment

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

these to also look similar

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 split them up because the second error message is helpful in giving a location for the error, i.e. it ends up looking like "the resource serde could not be found in dev-dependencies", and I liked having that location information.

Comment on lines +63 to +69
/// Failed to parse a version for a dependency
#[error("The version `{0}` for the dependency `{1}` couldn't be parsed")]
ParseReqVersion(String, String, #[source] semver::ReqParseError),

/// Failed to parse a version for a dependency
#[error("The version `{0}` for the dependency `{1}` couldn't be parsed")]
ParseVersion(String, String, #[source] semver::SemVerError),
Copy link
Collaborator

Choose a reason for hiding this comment

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

and these

Comment on lines +35 to +37
/// A value was expected but we got `None` instead
#[error("Expected a value")]
ExpectedValue,
Copy link
Collaborator

Choose a reason for hiding this comment

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

The previous name ReadHomeDirFailure was more descriptive, maybe we should remove it and map the err to an io error?

Comment on lines -161 to -162
if let Some(backtrace) = err.backtrace() {
eprintln!("Backtrace: {:?}", backtrace);
Copy link
Collaborator

Choose a reason for hiding this comment

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

@killercup are backtraces used for identifying bugs in cargo-edit if it fails to run on some input? maybe it's not that helpful for the users after all

@killercup
Copy link
Owner

Error handling is a hot topic in Rust right now -- like very spring, it seems -- and I love the discussion here, this is good stuff.

I have two more points. Don't read them as must-haves but just as random suggestions.

  • Looking at this huge error enum, would it make sense to instead split this up in an hierarchical fashion, so pretty much each module/file has its own Error type, and all the function would return Result<T, OurLocalError>? I've done this in some code bases at work and it was fine when it was just one or two layers of nesting.

    The main advantages are that the errors are more local and easier to read, and that the cause stack is very explicit. Also, since it's public enums, a consumer of the library (incl. us in the code for the binaries) can actually match on them. The main downside is that it's a large refactoring for no directly visible gain.

  • Can we use #[non_exhaustive] on our public error types?

@pwoolcoc
Copy link
Contributor Author

sorry for the radio silence, I'm still working on this and will try to update here as soon as I can

@pwoolcoc
Copy link
Contributor Author

I think I'm going to close this...I've tried a couple more things since the last time I updated this PR and haven't been happy with any of them and I don't think I'm going to come back to it. Sorry!

@pwoolcoc pwoolcoc closed this Oct 27, 2020
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.

Convert error to thiserror
4 participants