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 { ... }
    }
    
@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