Skip to content

Instantly share code, notes, and snippets.

@networkimprov
Last active March 10, 2024 22:08
Show Gist options
  • Star 5 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save networkimprov/961c9caa2631ad3b95413f7d44a2c98a to your computer and use it in GitHub Desktop.
Save networkimprov/961c9caa2631ad3b95413f7d44a2c98a to your computer and use it in GitHub Desktop.
Requirements to Consider for Go 2 Error Handling

Requirements to Consider for Go 2 Error Handling

Towards consensus on the requirements for a new errors idiom.

The Go2 Error Handling Overview gives only four rather vague "goals": small-footprint error checks, developer-friendly error handlers, explicit checks & handlers, and compatibility with existing code. It makes no mention of potential future extensions of the concept. Previously @ianlancetaylor made a similar list in Github issue #21161.

As of this writing, there are over thirty counter-proposals on the feedback wiki for the Go 2 Error Handling Draft Design. Almost none of them discuss the requirements that motivate them, but many imply requirements that the draft design does not fulfill. The Go team might benefit from a consensus on requirements before penning a next draft.

Below is a comprehensive menu of possible requirements (i.e. it's not all-or-nothing). Please comment with any others that should be considered.

PS: For those wondering why I've taken the trouble, it's because I had this reaction to the draft design :-)

This hit the front page of Hacker News in October 2018.

The Menu

A. Allow Go1 error idiom to continue working if err != nil { ... }

B. Readable, reusable error "handlers" within a function and/or package.

  1. Uniquely identify handlers (required by (C) below), e.g.

    handle h T { return h }     // id is parameter name
    handle h(p T) { return p }  // id is handler name
    h: handle p T { return p }  // id is label
    

    1.1 Let handler identity be visible anywhere within its enclosing scope.

  2. Let handler body contain any code that's valid in the enclosing function.

    2.1 Disallow "bare return" in a handler body, e.g.

    func f() (i int, e error) {
       ...
       handle h T { return }    // compile error
    }
    
  3. Let handler parameter be of any type, or only type error

    3.1 Perform implicit type assertion if parameter type implements an interface appearing in invocation of handler, e.g.

    func (t *T) Error() string { ... }  // T implements error
    func (t *T) x() { ... }
    ?h = func() error { return T{} }()  // invoke handler h with value type error
    handle h T { h.x() }                // implicit h = value.(T)
    
  4. Invoke handler body on any parameter value indicating error (typically non-zero).
    Or, for a slice parameter type, on any element value indicating error.

    4.1 Let a guard expression indicate the condition in which to run the handler body, e.g.

    handle h string; h == "failure" { ... }
    
  5. Permit handlers for deferred function calls, e.g.
    defer handle h T { return h }

C. Clear, concise way to select a handler by name (17 posts on the feedback wiki suggest this). Prevent the dissociation between function calls and handler blocks that occurs in the try/catch model.

  1. Let assignment invoke a handler (13 posts on the feedback wiki suggest this).

  2. Let function call invoke a handler.

// op: keyword or symbol (e.g. ? @ # and unambiguous pairings)
//     space between symbol and identifier is not required
// hname: handler name

v, hname op := f(a)   // assignment
v, op hname := f(a)

v := op(f(a), hname)  // function call
v := op(hname, f(a))
v := f(a) op hname
v := hname op f(a)
v := f op hname (a) // n.b.

D. Minimize boilerplate and name proliferation.

  1. Infer handler name and type from previous statement, e.g.

    op hname = f()
    handle { return fmt.Errorf(..., hname) }
    
  2. Let name of unreachable handler be reused, e.g.

    op hname = f(1)
    handle hname T { ... }
    op hname = f(2)
    handle hname T { ... }
    

E. Let function returning error and a value serve as expression, e.g.

f := func(i int) (T, error) { ... }
x(f op hname (a))
f op hname (a).x()
handle hname error { ... }
  1. Let return value passed to handler default to the last one.

  2. Let index of return value passed to handler be specified, e.g.
    x(f op hname : digit (a))

  3. Disallow expression syntax for assignment, e.g.
    v = f op hname (a) // compile error

F. Invoke handler on boolean false parameter for

v, op hname := m[k]
v, op hname := x.(T)
v, op hname := <-c
  1. Generate meaningful parameter value, e.g.
    v, op hname := m[k]
    handle hname error { return hname }
    

G. Let handling of an error skip over any statements between the statement triggering the handler and the handler body. (Entails placement of handler after statements that invoke it.) e.g.

op hname = f()
for {
   if x() { op hname = T{} }
}
handle hname T { ... }
  1. Let handling of an error from a deferred function call skip over any defer statements between the trigger and handler body, e.g.

    defer handle hname T { ... } // triggered by f()
    defer skipOnError()
    defer f op hname ()
    
  2. Implicitly set any variables declared between the first invocation of a handler and the handler body to their zero value, if they are read within the handler, e.g.

    op hname = f(1)
    v := 2
    op hname = f(v)
    handle hname T {
       fmt.Println(v) // always 0
    }
    

    2.1 Alternatively disallow use of such variables within the handler without a preceding assignment in-handler.

H. Let handler continue the function. (Entails placement of handler after statements that invoke it.) e.g.

for {
   op hname = f()
   handle hname T {
      if ... { continue }
      break
   }
   x()
}
  1. Let a special statement exit the handler, e.g.
    handle hname T { if ... { break op hname } }

  2. Let a handler invoked by a deferred function call continue up the defer stack, e.g.

    defer runLast()
    defer handle hname T { ... } // triggered by f()
    defer f op hname ()
    

I. Let handler invoke another handler explicitly, e.g.

handle err error {
   if err != io.EOF {
      op quit = err     // invoke "quit" handler
   }
}
...
handle quit T { ... }
  1. Let the invocation skip the nil check by making the handle parameter const.
    handle err error { err = fmt.Errorf(...) } // compile error

  2. Let handler invoke another handler implicitly, e.g. handlers with same name in related scopes (not recommended).

J. Let handler perform context accretion, e.g.

handle err error { op quit = fmt.Errorf("blurb: %v", err) }
  1. Provide invocation site metadata to handler, e.g. line number, called function (if any), parent function, source file.

K. Permit package-level handlers, essentially a custom "predefined handler", e.g.

package main
func f() {
   op hname = x()
}
handle hname T { ... }
  1. Provide caller name to handler, e.g.
    handle (hname T, caller string) { ... }

  2. Let handling of an error skip over any statements preceding a package-level handler reference, e.g.

    func f() {
       op hname = x()
       skipOnError()
       handle hname   // handler reference
       ...
    }
    handle hname T { ... }
    

L. Provide predefined handlers, e.g.

op _      = f() // ignore error
op        = f() // for f?()

op panic  = f() // panic on error
op p      + f() // for f?p(); or any substring of "panic"
op !      = f() // for f?!(); similar to "_" but perhaps cryptic

op return = f() // return on error; may discourage returning error with added context :-/
op r      = f() // for f?r(); or any substring of "return"
op .      = f() // for f?.(); similar to "_" but perhaps cryptic
  1. Let the ignore handler log errors or all values in a debug mode.

  2. Disallow the return handler in functions which don't define the requisite type as the last return value.

    2.1. Goroutine functions and main.main shall not return an error, or not any values.

M. Calls to functions returning type error shall invoke a handler for the error value, or assign it to a variable (i.e. not _).

op _ = f() // OK
 err = f() // OK
   _ = f() // compile error
       f() // compile error

N. Define keywords for handle and possibly op above. Consider any, or only C-family, or only Go1 keywords, e.g.

handle hname T { ... }
catch hname T { ... }
func op hname T { ... }

O. Let automated test procedures trigger handlers in functions being tested.

  1. Let the test tooling generate a variant of the function to test, with its handler invocations altered, e.g.
    func f(i int) {                                    // function to test
       op hname = x(i)
       handle hname T { ... }
    }
    
    // generate one of the following, as needed by test procedure
    
    func f_errors(i int, e ...interface{}) {           // argument per handler invocation
       op hname = e[0].(T)                             // or e[0].(func(int)T)(i)
       handle hname T { ... }
    }
    
    func f_errors(i int, e map[string][]interface{}) { // map of handler names, slice item per invocation
       if len(e[hname]) < 1 {
          op hname = x(i)
       } else {
          op hname = e[hname][0].(T)                   // or e[hname][0].(func(int)T)(i)
       }
       handle hname T { ... }
    }
    
@Kiura
Copy link

Kiura commented Sep 27, 2018

Adding try catch would make it much easier for developers I believe. Java has done a great job in that area.
an example of how this could be achieved

This seems to be better option to wrap the code in try catch that needs to be handled rather than creating named handle functions.
Although, package-level handlers sounds cool, it seems like it is gonna make error handling more implicit

@networkimprov
Copy link
Author

Go will never offer a try-block; it was deliberately excluded. It obscures the connection between an error's cause and effect, and the extra indent is unpopular. Go 2 will probably offer a per-call try, e.g. check f() or #hname = f() or f?hname().

@pkutishch
Copy link

Why don't just not touch error handling part? I love if err != nil

@networkimprov
Copy link
Author

networkimprov commented Sep 28, 2018

You could write the following, but it would be poor style :-)

v, err := f()
if err != nil { #hname = err }
...
handle hname error { ... }

@mewmew
Copy link

mewmew commented Oct 1, 2018

There are two issues with Go error handling today, which Go 2 may set out to solve.

  1. multiple returns for error handling prevents the use of chaining in Go 1
  2. checking errors of deferred functions is somewhat involved.

Chaining example

Often there are precisely two return values of a function or method, with the last one being used for error handling. If using check and handle (or some other mechanism) it would be possible to use chaining, that would enable new ways of writing code.

// go1
	t, err := F()
	if err != nil { ... }
	_, err := t.M()
	if err != nil { ... }

// go2
	_ = F().M()       // Would it be possible to do this?
	_ = check F().M() // Or with check?

Defer error check example

Defer is often used to cleanup resources after use, e.g. Close to close a file descriptor. However, in practice defer often seem to be used without taking into consideration the potential error return value. Sometimes this value is ignored for good reasons (there is nothing you intend to do with it). Other times, at least reporting the error to the user (close file foo failed), or better yet, trying to flush any pending writes, or write the file contents to a new file to ensure that no file contents is lost, may be preferable.

Searching in the Go standard library reveals quite a few such cases (grep -R -e "defer [a-zA-Z0-9_]\.Close" ~/go1.11/src/), e.g. from src/cmd/gofmt/gofmt.go:

func diff(b1, b2 []byte, filename string) (data []byte, err error) {
	f1, err := writeTempFile("", "gofmt", b1)
	if err != nil {
		return
	}
	defer os.Remove(f1) // <-- error not checked.

Other parts of the standard library implements custom error handling for checking errors of deferred functions, e.g. from src/testing/cover.go:

defer func() { mustBeNil(f.Close()) }()

@networkimprov
Copy link
Author

networkimprov commented Oct 1, 2018

Thank you for raising the defer issue! I've added items in B-5, G-1, and H-2. Note that without them, one could use

defer func() { op hname = f(); handle hname T { ... } }()

defer f op return () // predefined handlers
defer f op _ ()

Method chaining is covered by E, but I've added a line to its example with a method chain. Note that the check solution is unreadable:

check (check F()).M()

I need to edit your comment a little for brevity; pls don't take that as criticism :-)

@daved
Copy link

daved commented Oct 9, 2018

@mewmew
Chaining should normally be limited to accumulators and accumulator-like types. An error being returned is a great indication that chaining is not a good idea with that particular type.

When an error is returned by a construction function (or similar), it would be reasonable for the called scope to cleanup after itself. Requiring users to cleanup something that failed is unintuitive.

@sdwarwick
Copy link

sdwarwick commented Oct 30, 2018

Remarkably, I don't see anyone talk about how error handling patterns could be integrated with test harnesses. Providing syntaxes for simply eliciting an error pathway during test would be interesting.

Likewise, all of the error handling criterion you have so effectively outlined do not speak to "block level" error handling patterns as in errors thrown by context blocks rather than just function return. It would be great to consider that as well.

Also, it seems surprising that readability and clarity are not at the top of the list. Go seems to push above all else a programmer's ethic of WYSIWYG, so unlike many mature system languages. It would be so easy to lose that.

@networkimprov
Copy link
Author

networkimprov commented Oct 30, 2018

errors thrown by context blocks rather than just function return

C-1 lets assignment invoke a handler, and G lets invocation skip statements. That allows exiting a block. I'll clarify the example for G. Is that what you mean?

for ... {
   if ... { op hname = fmt.Errorf(...) }
}
handle hname error { ... }

Re triggering handlers from a test harness, the test caller could provide a handler input (or function to call) for each handler invocation site in the called function. That only entails tooling, not language features. Thoughts?

UPDATE: I posted a Q about this on golang-nuts.
UPDATE: Added in item O above.

func f(i int) {                          // function to test
   op hname = x(i)
   handle hname T { ... }
}
func f_errors(i int, e [1]interface{}) { // generated for go-test
   op hname = e[0].(T)                   // or e[0].(func(int)T)(i)
   handle hname T { ... }
}

I'll add "readable" to B. C already stipulates "clear" :-)

@bserdar
Copy link

bserdar commented Nov 5, 2018

There is another variation of the Item C-1 above: handler definition defines both a handler function and error variable:

handle err { ... }
v, err:=func()
if err!=nil {
}

This is different from the others because it combines go1 style error handling, named handlers, option for handlers to selectively handle errors, and handler chaining. (https://gist.github.com/bserdar/4c728f85ca30de25a433e84ad5a065a1)

@networkimprov
Copy link
Author

I could see handle hname T {...} as a definition like const, so that hname = f() invokes the handler. However that rules out G & H.

I can't see handle ... overloading a var. To know whether e = f() invokes a handler, you would have to scan the rest of the function.

@pcostanza
Copy link

There are situations where you want to muffle an expected error, in the sense that it shouldn't cause a return from a function. A typical example is io.EOF:

func f() (T, error) {
   handle err {
      if err == io.EOF {
          return nil
      }
      return err
   }

   ...
   check rd.Read(p)
   ...
}

So the return from function should actually be steered by the return value of the handler chain, not the initial error value.

@networkimprov
Copy link
Author

I think this is covered by B-2 "Let handler body contain any code that's valid in the enclosing function" and/or H "Let handler continue the function".

@rpbarbati
Copy link

Whatever the solution is, I would be looking for something that supports the following...

  1. Minimal code to implement (i.e. no error checking on every other line of code)
  2. Allow the error handler to be specified globally (one time at program start)
  3. Allow the error handler to be reusable (this probably implies the error handler is a package)
  4. Allow a new handler to be pushed and popped within a given scope. In other words, if I want to override the global handler in a function, I can, and when we leave the scope, the handler is popped back to the prior handler.
  5. Allow an Error to carry a type value (probably already possible)
  6. The Error type could be used to provide customized error handling within the error handler (all within the error handling package)

Over time, the above would lead to a state where the developer simply "go gets" the error handler packages that he or she prefers, and thereafter only concerns him or herself with only the most esoteric error handling. The best error handler packages would be able to recover from recoverable errors and panic when they can't.

@rpbarbati
Copy link

My above thoughts are all based primarily on the notion that as a developer, I want to focus on the happy path - functionality that works. The only thing I want to have to provide for error handling is to know that a reliable, intelligent error handler is going to capture and handle any errors that occur. I specifically do not want to have to think about errors on every function I call - this is because my response to ALL errors is going to be the same - recover if possible, fail if not. We should not have to restate this 1000 times throughout an application.

@rpbarbati
Copy link

I am referencing a document that describes an implementation of my two previous posts. Of note are that different errors can and would be handled differently - errors in file handling code could remove partially written files, errors in database code could rollback, etc. All of which would occur automatically within the packages you are using for an application. This solution also allows you to override and chain if desired either globally or within a defined scope...

https://docs.google.com/document/d/1i-O3QUvwNaltM3NQex5ggL1a9PFPb1mAPeV6WiaVd1A/edit?usp=sharing

Comments are more than welcome...

@networkimprov
Copy link
Author

Apology for the slow response; been working on software releases lately...

I'd encourage you to post a link to your document on https://github.com/golang/go/wiki/Go2ErrorHandlingFeedback

I'll also review and try to incorporate your points into the above Requirements menu when I have a chance.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment