hp
toc

Got hooked: error backing

2024-01-20, post № 282

systems-design, #implementation-pattern, #lang:go

‘Got hooked’ is a miniseries about my experiences and findings pursuing proper Go.

At first glance, type error interface{Error() string} may suggest a named abstraction ⸺“the error”⸺ with which one can only interact by querying its character representation.
Mindful of the inherent complexity spawned by the plethora of erroneous states a system might find itself in, and in lieu of academic progress the last decades could have brought infusing type systems with benign systems-engineering-relevant semantics, more pragmatically-inclined folks have substituted lack of semantics expressible within commonly understood typing concepts with soft contracts one is expected to follow does one wish to attain wholistic system cohesion. Most unfortunately, these soft contracts are most pitifully communicated.

It is not wise to depend on an error’s textual representation. It would be a shame if in 2815 the Extraterrestrial Orbit Federation’s explicit declaration of outage signalled the error-free termination of a response stream:

package comms

import "errors"

var (
    ErrOutage = errors.New("pkg.eof.io/interplanetary/comms: EOF outage")
    ...
)

Because of this, e. g. operating system errors since Go 1 are unerasable to an *os.PathError with explicitly marked domain-specific semantics. To prevent the above feared, ==io.EOF or errors.Is(,io.EOF) are superior to any atrocities one may come up with in strict compliance to the error interface, for example strings.Index(.Error(),"EOF")!=-1.

As such, it becomes apparent that more than just its character representation is expected of an error. If it is obtained by an os-mediated syscall, it is expected to be backed by *os.PathError. If it is to signal the end to a byte stream, it is expected to be (equality is not expressed through its interface) what io declares.
And since the specifics of an error’s implementation are not sufficiently abstracted away by the error interface as presently interpreted, due care needs to be put into deciding on such a backing implementation. Herein, I will present four I discovered in Go’s std source and through my personal development efforts.

a) Most classically, one exports errors as var declarations:

package wire

import (
    "errors"
    "fmt"
)

var (
    ErrSemanticsPacket = errors.New("gitproto v2 semantics")
    ErrFlushPacket = fmt.Errorf("%w: 0000 flush-pkt", ErrSemanticsPacket)
)

Crucially, such definitions are quickly written and interface{Unwrap() error} is the same satisfiable in a pinch.
What keeps the paranoidal awake at night is this approaches necessity of a soft contract ensuring error immutability (although other forms of trust still need to be exercised in regards to dependencies and system parts in general). Among its companions const, func and type, var is the only means of exporting a name which may be set by the caller⸺barring unsafe.
Ever since I saw the following⸺hitherto fantastical⸺, I heed its possibility:

package wisdom

import (
    "io"
    "io/fs"
    "net"
)

func init() {
    io.EOF = nil
    net.ErrClosed = fs.ErrClosed
}

b) Plain as a pikestaff, an error qua its purpose of relaying distinct semantics must in its definition be totally controlled by this meaning’s originator, the package which defines the error. Naturally, a const export [1] lends itself well to model the very:

package git

type error_t int8
const (
    _ = error_t(iota)
    ErrBadChecksum
    ErrBadObject
    ErrIncompleteImplementation
)

func (err error_t) Error() string {
    switch err {
    default:
        panic("unreachable")
    case ErrBadChecksum:
        return "bad checksum"
    case ErrBadObject:
        return "bad object"
    case ErrIncompleteImplementation:
        return "incomplete implementation"
    }
}

Much to the delight of my night’s rest, no third party may now coerce my implementation into not reporting its deficiencies (by e. g. setting git.ErrIncompleteImplementation = nil).
Unfortunately, this approach leaks information beyond both the error interface and the error concept: a cast the likes of int(ErrIncompleteImplementation) leaks the number three which⸺now part of the package’s public API⸺is subject to versioning and forever set in stone lest a bump is imperative.

c) Trying to remedy backing value leakage, one might wrap in an unexported manner:

package git

type error_t struct{e int8}
var (
    ErrBadChecksum = error_t{1}
    ErrBadObject = error_t{2}
    ErrIncompleteImplementation = error_t{3}
)

func (err error_t) Error() string {
    switch err.e {
    default:
        panic("unreachable")
    case ErrBadChecksum.e:
        return "bad checksum"
    case ErrBadObject.e:
        return "bad object"
    case ErrIncompleteImplementation.e:
        return "incomplete implementation"
    }
}

Now due to error_t both being non-exported and non-integral, any outside actor cannot construct a bogus error_t. But, alas, since more than one is defined, mayham is not yet out of the question:

package cheeky

import "pkg.jfrech.com/go/git"

func init() {
    git.ErrIncompleteImplementation = git.ErrBadChecksum
}

d) All this culminates in me opting to define unexported, named struct{}s which get their backing value indirectly through the interface mechanism’s RTTI type IDs and their immutability through being of a singleton type:

package git

var ErrBadChecksum errBadChecksum
type errBadChecksum struct{}
func (_ errBadChecksum) Error() string {
    return "bad checksum"
}

var ErrBadObject errBadObject
type errBadObject struct{}
func (_ errBadObject) Error() string {
    return "bad object"
}

var ErrIncompleteImplementation errIncompleteImplementation
type errIncompleteImplementation struct{}
func (_ errIncompleteImplementation) Error() string {
    return "incomplete implementation"
}

It may be a tad much to type, but eliminates all fears described above in their entirety. Unwrapping semantics must also be manually defined for each error.

Footnotes

  1. The other often-used approach to modeling constants is a nullary function, e. g. func ErrIncompleteImplementation() error { return errors.New("incomplete implementation") }. Since this completely breaks the error’s identity, such an export is a fool’s errand.
Jonathan Frech's blog; built 2024/04/13 20:55:09 CEST