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
- ▲ 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.