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

User-facing errors in example runner #2741

Merged
merged 12 commits into from
Sep 15, 2023
15 changes: 15 additions & 0 deletions internal/errs/errs.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ type TransientError interface {
IsTransient()
}

type UserFacingError interface {
MDrakos marked this conversation as resolved.
Show resolved Hide resolved
UserFacingError() error
}

// PackedErrors represents a collection of errors that aren't necessarily related to each other
// note that rtutils replicates this functionality to avoid import cycles
type PackedErrors struct {
Expand Down Expand Up @@ -258,3 +262,14 @@ func Unpack(err error) []error {
}
return result
}

func UserFacing(err error) (error, bool) {
MDrakos marked this conversation as resolved.
Show resolved Hide resolved
errs := Unpack(err)
for _, err := range errs {
if uerr, ok := err.(UserFacingError); ok {
return uerr.UserFacingError(), true
}
}
MDrakos marked this conversation as resolved.
Show resolved Hide resolved

return err, false
}
6 changes: 5 additions & 1 deletion internal/runbits/hello_example.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,14 @@ import (
"github.com/ActiveState/cli/internal/output"
)

type NoNameProvidedError struct {
*locale.LocalizedError
}

func SayHello(out output.Outputer, name string) error {
if name == "" {
// Errors that are due to USER input should use `NewInputError` or `WrapInputError`
Copy link
Contributor

Choose a reason for hiding this comment

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

This comment is now incorrect and causing confusion @Naatan @MDrakos

return locale.NewInputError("hello_err_no_name", "No name provided.")
return &NoNameProvidedError{locale.NewInputError("hello_err_no_name", "No name provided.")}
MDrakos marked this conversation as resolved.
Show resolved Hide resolved
}

out.Print(locale.Tl("hello_message", "Hello, {{.V0}}!", name))
Expand Down
53 changes: 47 additions & 6 deletions internal/runners/hello/hello_example.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,16 @@ type primeable interface {
primer.Projecter
}

// HelloUserFacingError is an error from this runner that is intended to be
// shown to the user. It is a wrapper around a localized error.
type HelloUserFacingError struct {
*locale.LocalizedError
}

func (e *HelloUserFacingError) UserFacingError() error {
return e
}
MDrakos marked this conversation as resolved.
Show resolved Hide resolved
MDrakos marked this conversation as resolved.
Show resolved Hide resolved

// RunParams defines the parameters needed to execute a given runner. These
// values are typically collected from flags and arguments entered into the
// cli, but there is no reason that they couldn't be set in another manner.
Expand All @@ -37,9 +47,7 @@ type RunParams struct {
// can be set. If no default or construction-time values are necessary, direct
// construction of RunParams is fine, and this construction func may be dropped.
func NewRunParams() *RunParams {
return &RunParams{
Name: "Friend",
}
return &RunParams{}
}

// Hello defines the app-level dependencies that are accessible within the Run
Expand All @@ -58,8 +66,40 @@ func New(p primeable) *Hello {
}
}

// Run contains the scope in which the hello runner logic is executed.
// Run wraps the scope in which the hello runner logic is executed. This
// is to ensure we catch specific errors that we are looking for and wrap
// them in a user-facing error.
func (h *Hello) Run(params *RunParams) error {
err := h.run(params)
if err != nil {
MDrakos marked this conversation as resolved.
Show resolved Hide resolved
for _, unpackedError := range errs.Unpack(err) {
switch unpackedError.(type) {
MDrakos marked this conversation as resolved.
Show resolved Hide resolved
case *runbits.NoNameProvidedError:
// Errors that we are looking for should be wrapped in a user-facing error.
// Ensure we wrap the top-level error returned from the runner and not
// the unpacked error that we are inspecting.
return &HelloUserFacingError{
locale.WrapInputError(
err,
"hello_err_no_name",
"Cannot say hello because no name was provided.",
),
}

default:
// If this is not a specific error we are looking for, do nothing.
continue
}
MDrakos marked this conversation as resolved.
Show resolved Hide resolved
}

return locale.WrapError(err, "hello_cannot_say", "Cannot say hello.")
}

return nil
}
Copy link
Member

Choose a reason for hiding this comment

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

I don't like the approach of having a Run calling another run, where the first one is basically a post-processor of the latter. That feels like an inverse of responsibilities in how you traverse the code when reading.

I think we should have a single Run function, and we defer the error "post processor". So at the start of the run function we'd have something like

func processError(err *error) {
	...
	*err = errs.WrapUserFacing(err, "You broke it!")
    ...
}

func (h *Hello) Run(params *RunParams) (rerr error) {
    defer processError(&rerr)
    ...
}

Copy link
Member Author

@MDrakos MDrakos Sep 7, 2023

Choose a reason for hiding this comment

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

I prefer the approach that is currently in the PR. While having a function that does post-processing + defer fits nicely in a mental model in practice I find that when reading the code it requires more jumping around, or at least remembering that you have to jump to the defer function after reading the runner logic. This isn't a problem with the current code as you read the Run function, then the function that it calls, and return to the caller.

Post-processing with a defer also requires a named return value which is different from the vast majority of the functions in our codebase and also adds another layer of complexity when reading the code.

This is all subjective and in the end, it's your call.

Copy link
Member

Choose a reason for hiding this comment

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

The pattern of the named return is not uncommon. It is used frequently in our codebase.

I'm open to other solutions, but between the current implementation and what I am suggesting I'd prefer my suggestion. Giving each runner a redundant Run() function that you have to interpret to understand does not feel like the better solution to me, but you're right, that is subjective. But, given this is meant as a reference for our codebase rather than a one-off piece of code I'm gonna be nitpicky and insist on either my solution or an alternate yet to be determined one.


// Run contains the scope in which the hello runner logic is executed.
func (h *Hello) run(params *RunParams) error {
h.out.Print(locale.Tl("hello_notice", "This command is for example use only"))

if h.project == nil {
Expand All @@ -79,7 +119,7 @@ func (h *Hello) Run(params *RunParams) error {
// runners. Runners should NEVER invoke other runners.
if err := runbits.SayHello(h.out, params.Name); err != nil {
// Errors should nearly always be localized.
return locale.WrapError(
return locale.WrapInputError(
MDrakos marked this conversation as resolved.
Show resolved Hide resolved
err, "hello_cannot_say", "Cannot say hello.",
)
}
Expand Down Expand Up @@ -127,7 +167,8 @@ func currentCommitMessage(proj *project.Project) (string, error) {

commit, err := model.GetCommit(proj.CommitUUID())
if err != nil {
return "", locale.NewError(
return "", locale.WrapError(
err,
"hello_info_err_get_commitr", "Cannot get commit from server",
)
}
Expand Down
8 changes: 4 additions & 4 deletions pkg/cmdlets/errors/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,6 @@ func ParseUserFacing(err error) (int, error) {
return 0, nil
}

_, hasMarshaller := err.(output.Marshaller)
Copy link
Member

Choose a reason for hiding this comment

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

This change is breaking the original intend of the function. Just way predated the new design and isn't looking to address the same use-case. What you effectively found here is that the term "User Facing" was already in use, and we need to either resolve the conflict of overlapping terms, or ensure that they are compatible.

Looking through the code it seems hasMarshaller will mainly identify whether we already have an OutputError on our hands, so we don't redundantly wrap it. While I don't see any scenario's in which this could be true, do we want to risk breaking this behaviour for this PR?

Please take some time to understand what OutputError{} is doing and how the new concept of user facing errors plays into this.

Copy link
Member Author

Choose a reason for hiding this comment

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

From what I can grok the OutputError type adds extra post-processing and the handling in this function ensures that errors returned all have a similar UX. I've wrapped the user-facing error in this OutputError type as well to ensure it's consistent with all of the other errors we are presenting to users.

I've also added back the hasMarshaller check as you were right, we don't want to wrap the error again in the OutputError type.


// unwrap exit code before we remove un-localized wrapped errors from err variable
code := errs.ParseExitCode(err)

Expand All @@ -103,8 +101,10 @@ func ParseUserFacing(err error) (int, error) {
return code, nil
}

if hasMarshaller {
return code, err
uerr, ok := errs.UserFacing(err)
if ok {
logging.Debug("Returning user facing error, error stack: \n%s", errs.JoinMessage(err))
return code, uerr
}

return code, &OutputError{err}
Expand Down
Loading