errorcontext

package module
v0.4.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 24, 2025 License: MIT Imports: 3 Imported by: 5

README

blugnu/errorcontext

TL;DR: to get started, go get github.com/blugnu/errorcontext (or later, if available)

A go package providing an error implementation that wraps an error together with a context.Context.

Tech Stack

blugnu/errorcontext is built on the following main stack:

Full tech stack here

History

This module is a ground-up re-write of the previously released (and still available) go-errorcontext module. This new implementation incorporates a number of improvements and simplifies the API.

It has been renamed as errorcontext rather than being a v2 release in order to remove the cumbersome go- prefix from the module name.

Creating Errors

Factory functions are provided to create/wrap contextual errors in a variety of circumstances:

function description use in place of ...
New(ctx, s) creates a new error wrapping a Context with an error created using errors.New() with a supplied string errors.New(s)
Errorf(ctx, format, args...) creates a new error using fmt.Errorf() given a format string and args fmt.Errorf(s, args...)
Join(ctx, err...) uses errors.Join() to consolidate multiple errors and wrap the result (if not nil) with a specified context errors.Join(err1, err2, ...)
Wrap(ctx, err...) creates a new error, wrapping a Context with one or two specified errors fmt.Errorf("%w: %w", err1, err2)
or
errors.Wrap(err1, err2)

All functions require a Context.

example: New()
    if len(sql) == 0 {
        return errorcontext.New(ctx, "a sql statement is required")
    }
example: Errorf()
1. formatting a new error
    if len(pwd) < minpwdlen {
        return errorcontext.Errorf(ctx, "password must be at least %d chars", minpwdlen)
    }
2. adding narration to an error
    if err := db.QueryContext(ctx, sql, args); err != nil {
        return errorcontext.Errorf(ctx, "db query: %w", err)
    }
example: Join()

Wrapping an arbitrary collection of possibly nil errors:

    err1 := Operation1(ctx)
    err2 := Operation2(ctx)
    if err := errorcontext.Join(ctx, err1, err2); err != nil {
        return err
    }
example: Wrap()
1. wrapping an existing error
    if err := db.QueryContext(ctx, sql, args); err != nil {
        return errorcontext.Wrap(ctx, err)
    }
2. wrapping two errors

When wrapping two (2) errors they are composed into an error: cause error chain, equivalent to Wrap(ctx, fmt.Errorf("%w: %w", err1, err2)); this is a useful pattern for attaching a sentinel error to some arbitrary error, typically to simplify testing:

    if err := db.QueryContext(ctx, sql, args); err != nil {
        return errorcontext.Wrap(ctx, ErrQueryFailed, err)
    }

A test of a function containing this code can check for the sentinel error without being coupled to details of the error returned by the function called by the function under test, for example:

    if err := Foo(ctx); !errors.Is(err, ErrQueryFailed) {
        t.Errorf("expected ErrQueryFailed, got %v", err)
    }

Working With Errors

TheContext captured by an ErrorWithContext may be obtained (if required) by determining whether an error is (or wraps) an ErrorWithContext. If an ErrorWithContext is available from an error the Context() function may then be called to obtain the Context associated with the error:

ctx := context.Background()

// ...

if err := Foo(ctx); err != nil {
    ctx := ctx // shadow ctx for the context associated with the error, if different from the current ctx
    ewc := ErrorWithContext{}
    if errors.As(err, &ewc) {
        ctx = ewc.Context()
    }
    // whether ctx is still the original or one captured from the error,
    // it is the most enriched context available and can be used to
    // initialize a context logger, for example
    log := logger.FromContext(ctx)
    log.Error(err)
}

The errorcontext.From() helper function provides a convenient way to do this, accepting a default Context (usually the current context) to use if no Context is captured by the error, simplifying the above to:

    if err := Foo(ctx); err != nil {
        ctx := errorcontext.From(ctx, err)
        log := logger.FromContext(ctx)
        log.Error(err)
    }

NOTE: The Context() function will recursively unwrap any further ErrorWithContext errors to return the Context associated with the most-wrapped error possible. This ensures that the most enriched Context that is available is returned.



Intended Use

ErrorWithContext is intended to reduce "chatter" when logging errors, particularly when using a context logger to enrich structured logs.

The Problem

  1. A Context enriched by a call hierarchy is most enriched at the deepest levels of a call hierarchy.
  2. Idiomatically wrapped errors provide the greatest narrative at the shallowest level of that call hierarchy.

This may be demonstrated with an example:

func Bar(ctx context.Context) error {
    return errors.New("not implemented")
}

func Foo(ctx context.Context, arg int) error {
    ctx := context.WithValue(ctx, fooKey, arg)
    if err := Bar(ctx, arg * 2); err != nil {
        return fmt.Errorf("Bar: %w", err)
    }
    return nil
}

func main() {
    ctx := context.Background()
    if err := Foo(ctx, 42); err != nil {
        log.Fatalf("Foo: %s", err)
    }
}

This produces the output:

FATAL message="Foo: Bar: not implemented"

The error string, as logged, describes the origin of the error.

However, the Context available at the point at which the error is logged contains none of the keys which might be used by a context logger to enrich a log entry with additional information not available in the error string.

If a context logger is used to log an error with that enrichment, deep within the call hierarchy, the error string lacks the additional narrative obtained by passing the error back up the call hierarchy. But if every function that receives an error does this then the log becomes very noisy and potentially confusing if context logging is not consistently used:

func Bar(ctx context.Context) error {
    log.Error("not implemented")
    return errors.New("not implemented")
}

func Foo(ctx context.Context, arg int) error {
    ctx := context.WithValue(ctx, fooKey, arg)
    if err := Bar(ctx, arg * 2); err != nil {
        log := logger.FromContext(ctx)
        log.Errorf("Bar: %s", err)
        return fmt.Errorf("Bar: %w", err)
    }
    return nil
}

func main() {
    ctx := context.Background()
    if err := Foo(ctx, 42); err != nil {
        log.Fatalf("Foo: %s", err)
    }
}

Which might produce log output similar to:

ERROR message="not implemented"
ERROR foo=42 message="Bar: not implemented"
FATAL message="Foo: Bar: not implemented"

there is a lot else wrong with the error handling and reporting in this example; it is intended only as an illustration and as such deliberately presents a potential worst case


ErrorWithContext addresses this problem by providing a mechanism for returning the context at each level back up the call hierarchy together with the error that occurred.

A simple convention then ensures that the error is logged only once and with the greatest possible context information available from the source of the error.

The convention has two parts:

  1. If an error is returned, it is not logged but returned as an ErrorWithContext (if a local Context is available), or at least returned without context

  2. If an error is not returned (usually at the effective or actual root of a call hierarchy, e.g. in a http handler) it is logged using a context logger initialized from context captured with the error (if any)

Informational and warning logs may of course continue to be emitted at every level in the call hierarchy.

Applying this convention to the previous example illustrates the benefits:

func Bar(ctx context.Context) error {
    log.Error("not implemented")
    return errorcontext.New(ctx, "not implemented")
}

func Foo(ctx context.Context, arg int) error {
    ctx := context.WithValue(ctx, fooKey, arg)
    if err := Bar(ctx, arg * 2); err != nil {
        return errorcontext.Errorf("Bar: %w", err)
    }
    return nil
}

func main() {
    ctx := context.Background()
    if err := Foo(ctx, 42); err != nil {
        ctx := errorcontext.From(err)
        log := logger.FromContext(ctx)
        log.Fatalf("Foo: %s", err)
    }
}

which might result in output similar to:

FATAL foo=42 message="Foo: Bar: not implemented"

Error handling is simplified and idiomatic, with the benefit of both fully enriched context logging and descriptive error messages.

Documentation

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Errorf

func Errorf(ctx context.Context, format string, args ...any) error

Errorf creates a new error with the supplied Context. The function is an analog for fmt.Errorf.

func From

func From(ctx context.Context, err error) context.Context

From accepts a current context and an error and returns the context from the 'most wrapped' error with Context. If the error is not an ErrorWithContext (and does not wrap one) the supplied `context` is returned.

func Join added in v0.3.0

func Join(ctx context.Context, errs ...error) error

Join creates a new error wrapping any number of supplied errors with a specified context. The function is an analog for errors.Join.

If all errors are nil then the function returns nil.

func New

func New(ctx context.Context, s string) error

New creates a new error with the supplied Context. The function is an analog for errors.New.

func Wrap

func Wrap(ctx context.Context, err error, errs ...error) error

Wrap creates a new error wrapping one or more errors with a specified context.

The error returned by the function wraps the specified error(s) differently, depending on the number of non-nil errors specified.

Called with 1 Error

If only one non-nil error is specified it is wrapped.

Called with 2 Errors

If two errors (a, b) are specified and both are not nil, then both errors are wrapped within a new error of the form "a: b".

i.e. the following are equivalent statements when a and b are non-nil:

errorcontext.Wrap(ctx, a, b)
errorcontext.Wrap(ctx, fmt.Errorf("%w: %w", a, b))

If either a or b are nil (but not both), the non-nil error is wrapped.

If both a and b are nil, the function returns nil.

Called with > 2 errors

If more than two errors are specified, the function behaves similarly to the case with two errors, with all errors other than the first joined.

i.e. the following are equivalent statements when a, b, and c are non-nil:

errorcontext.Wrap(ctx, a, b, c)
errorcontext.Wrap(ctx, fmt.Errorf("%w: %w", a, errors.Join(b, c)))

If a is nil, the function returns an ErrorWithContext that wraps the joined errors b and c.

Types

type ErrorWithContext

type ErrorWithContext struct {
	// contains filtered or unexported fields
}

ErrorWithContext wraps an error with a context.

func (ErrorWithContext) Context

func (err ErrorWithContext) Context() context.Context

Context returns the inner-most context accessible from this error or any wrapped ErrorWithContext.

That is, the wrapped error is tested to determine if it is (or wraps) an ErrorWithContext and if it is the Context() function on that wrapped error is called. This continues recursively until there are no more ErrorWithContext errors to be unwrapped.

func (ErrorWithContext) Error

func (err ErrorWithContext) Error() string

Error implements the error interface.

func (ErrorWithContext) Is added in v0.2.1

func (err ErrorWithContext) Is(target error) bool

Is compares an ErrorWithContext with some target error to determine whether they are considered equal.

A receiver will match with a target that: - is an ErrorWithContext; and - has an equal or nil context; and - has a nil error or an error which satisfies errors.Is(target.error, receiver.error)

func (ErrorWithContext) Unwrap

func (err ErrorWithContext) Unwrap() error

Unwrap returns the error wrapped by the ErrorWithContext.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL